tl;dr
Use redux if you need a global undo function, you need to work with streams, or if referential transparency is important.
Contents
- What's up with redux?
- General Tradeoffs
- What's redux?
- What's redux-observable?
- Wait What's an Observable?
- Ok so what's redux-observable agian?
- Advantages of Global State
- Sum Type Utility
What's up with redux?
At the time of writing in 2020, redux
and redux-observable
appear frequently on job descriptions (although more often they ask for experience with redux-saga, which is similar but less powerful). State management is a popular buzzword. What are these things and why would you use them?
Since I consider redux
almost useless without redux-observable
, I'll refer to them interchangeably through this article, although they are in fact separate.
For simplicity's sake, we'll be talking about redux
as it relates to react
for this post. react
is a goot fit, since redux
is meant to model elm, which it does most effectively when paired with react
.
General Tradeoffs
redux
is not the only popular state management library. Here's an overview of redux as compared to its major alternatives
redux
Pros:
- implementing global undo is trivial
- natural solution for global event handling (aka routing)
- seamlessly integrates with timers, progress indicators, websockets, any kind of "streaming" data source
- sophisticated debug system
- global state = illegal states become unrepresentable. Reduce or eliminate null checking
- nearly total referential transparency
- decoupled side effects enable dependency injection which aids testing
Cons:
- colocated state is faster (though React.memo can solve this)
- colocated state can be simpler to reason about
- colocated state is kinda the whole point of react (though you're certainly still allowed to use hooks for textfield input etc)
- adding new behavior is cumbersome
- applications with many different behaviors can become unwieldy
- the debug system is a bit arbitrary unless paired with a hot reloader
recoil
Pros:
- variably memoized changes of shared state across deeply nested components
- good for projects that render many expensive & memoiz-able components, e.g. spreadsheets or flowcharts
Cons:
- no compile time guarantees about application state as a whole
react-query
Pros:
- logical separation of "server cache", which models async data as a cache, and "client state", which more directly affectts the ui
- polls a server with periodic refetching
Cons:
- tight coupling of render logic and fetching logic
- no compile-time guarantees about application state as a whole
mobx
Pros:
Cons:
- mutable state
- run imperative code based on derived state
My general take is that state management is often a solution in search of a problem. redux
became popular as a solution to prop drilling, which can now be solved with simple react context. Unless you need some of the benefits outlined above, I would advise against using a state management library at all. react
itself is a fantastic state management libary.
Full disclosure: I have zero production experience with any of these besides redux. The rest of the pros and cons are from cursory research, so take them with a big grain of salt. If you have any relevant experience, ideas or corrections please let me know in the comments!
What's redux?
redux
models the elm architecture. The application keeps all its state in one object at the root level ('state' in redux
, 'model' in elm). All possible state changes are represented as a sum type ('Action'1 in redux
, 'Msg' in elm). There's a function that takes the old state and an action as inputs, and outputs a new state ('reducer' in redux
, 'update' in elm)
const reducer: (state: State, action: Action) => State = ...
It's so simple, we can easily implement it ourselves:
import React, { useState } from 'react'
type State = number
enum Action {
Increment,
Decrement
}
const reducer = (state: State, action: Action): State => {
switch(action) {
case Action.Increment:
return state + 1
case Action.Decrement:
return state - 1
}
}
const Root = () => {
const [state, setState] = useState<State>(0)
const dispatch = (action: Action) => setState(reducer(state, action))
return (
<>
<div>
Count: {state}
</div>
<button
onClick={() => dispatch(Action.Increment)}
>
+
</button>
<button
onClick={() => dispatch(Action.Decrement)}
>
-
</button>
</>
)
}
That's the basic idea!
In react-redux
, the root State
and the dispatch
function are both wrapped in react context so they can be accessed by every component.
While there's a little more to it, this is enough to move on (and enough for ~80% of your work with redux)
What's redux-observable?
What if we need to change the state asynchronously?
type Data = ...
const fetchAsyncData: () => Promise<Data> = ...
const reducer = (state: State, action: Action): State => {
switch(action.type) {
case 'FetchData':
const newState: Promise<State> = fetchAsyncData()
.then((asyncData: Data): State => ({
...state,
asyncData,
}))
return ???
}
}
We can't return newState
because reducers are synchronous. We'll need an async middleware to handle this case. What does that mean?
redux
uses middleware, just like express
does. This means that you can add additional functionality to your dispatcher. This is how you use the sophisticated debug system I mentioned earlier.
redux-observable
is the most powerful async middleware. It combines an Observable
with the dispatcher
.
Wait What's an Observable?
Observable
comes from the library rxjs
. It represents a stream of data.
What's a stream? Where Promise
represents an asynchronous value with one single output, an Observable
represents an asynchronous value with many.
The same way you would model the callback in setTimeout
as a Promise
, you would model the callback in setInterval
as an Observable
import { Observable } from 'rxjs'
const output: Promise<string> = new Promise(res => {
setTimeout(() => res('output'), 1000)
})
const output$: Observable<string> = new Observable(sub => {
setInterval(() => sub.next('output'), 1000)
})
output.then(console.log)
// output
output$.subscribe(console.log)
// output
// output
// output
// ...
By convention, we use the '$' character as a suffix for Observable
values.
rxjs
is powerful because Observable
has many combinators and operations
A quick note: like Promise
, Observable
models unchecked exceptions with optional error handling. If these concepts are unfamiliar, check out my article Either vs Exception Handling. I recommend using an Either
type to model errors with more type safety. fp-ts-rxjs
provides a great type alias called ObservableEither
that's similar to TaskEither
for this purpose.
Ok so what's redux-observable agian?
redux-observable
adds a function called an Epic
, which takes an Observable<Action>
and returns an Observable<Action>
const epic: (action$: r.Observable<Action>) => r.Observable<Action> = ...
Let's revisit our earlier example, this time with redux-observable
:
import { pipe } from 'fp-ts/pipeable'
import * as r from 'rxjs'
import * as ro from 'rxjs/operators'
interface FetchData { type: 'FetchData' }
interface UpdateData { type: 'UpdateData'; data: Data }
type Action = FetchData | UpdateData
const epic = (action$: r.Observable<Action>): r.Observable<Action> => pipe(
action$,
ro.filter((action: Action): action is FetchData => action.type === 'FetchData'),
ro.map((_: FetchData): Promise<Data> => fetchAsyncData()),
ro.switchMap((resp: Promise<Data>): r.Observable<Data> => r.from(resp)),
ro.map((asyncData: Data): Action => ({ type: 'UpdateData', asyncData })),
)
const reducer = (state: State, action: Action): State => {
switch(action.type) {
case 'UpdateData':
return {
...state,
action.data,
}
default:
return state
}
}
If you're unfamiliar with pipe
syntax, check out Ryan Lee's excellent Practical Guide to fp-ts part 1 (the whole series is excellent but part 1 deals specifically with pipe
).
This may seem like a complex way to update state asynchronously, and it is. Here's comparable code using vanilla react:
import React, { useState } from 'react'
const AsyncData = () => {
const [asyncData, setAsyncData] = useState<Data | undefined>(undefined)
const onClick = () => fetchAsyncData().then(setAsyncData)
...
}
Why would we use redux-observable
? As I mentioned earlier, Observable
has many operators and combinators. We can easily compose multiple streams together. If this was all the functionality we needed, I would say that redux-observable
is a bad fit.
What if we also wanted to delete our async data whenever the delete key is pressed? We would simply merge the streams
const epic = (action$: r.Observable<Action>): r.Observable<Action> => pipe(
r.merge(
pipe(
action$,
ro.filter((action: Action): action is FetchData => action.type === 'FetchData'),
ro.map((fetchData: FetchData): Promise<Data> => fetchAsyncData()),
ro.flatMap((promResponse: Promise<Data>): r.Observable<Data> => r.from(promResponse)),
),
pipe(
r.fromEvent<KeyboardEvent>(window, 'keydown'),
ro.filter(e => e.which === 8), // 8 is the key code for 'delete'
ro.map(() => undefined),
),
),
ro.map((asyncData: Data): Action => ({ type: 'UpdateData', asyncData })),
)
redux-observable
is great for global event handling because we have access to the global state. Here, we're able to keep our code naturally DRY because our FetchData
action and our key event both trigger the UpdateData
action.
Anyway, it always feels wrong to add an event handler like this to some componentDidMount
function. Why would we wait for a component to mount to start handling the event? What does a key press have to do with a component at all? redux-observable
provides a more natural place for this.
Here's the full code, in case you want to see how to properly set up the redux-observable
middleware.
Advantages of Global State
Like elm, redux
manages global state. There are well-documented disadvantages to using global state.
However, whether or not you end up using redux
, global state is often the best way to model data. It has a major advantage often overlooked: type safety.
Let's say we need to implement authentication in a web app. Certain routes and data can only be accessed after we've been logged in. Here's how that might look with vanilla react:
import { Route } from 'react-router-dom'
interface AppState {
user?: User
data?: SecureData
}
const Routes = ({ state }: { state: AppState }) => (
<>
<Route
path="/authenticated"
>
<Authenticated
user={state.user}
data={state.data}
/>
</Route>
<Route
path="/unauthenticated"
>
<Unauthenticated/>
</Route>
</>
)
We have a few potential errors here:
- What do we display we're at '/authenticated' but we have no
user
or nodata
? - What if we have a
user
but no data, or vice versa?
We could solve this with null checking.
const Routes = ({ state }: { state: AppState }) => (
<>
<Route
path="/authenticated"
>
{state.user && state.data && (
<Authenticated
user={state.user}
data={state.data}
/>
)}
</Route>
<Route
path="/unauthenticated"
>
<Unauthenticated/>
</Route>
</>
)
This is a bad solution. What if we have no user
or data
and we're at the /authenticated
route? This should never be possible, but at compile time it could be. We have introduced a possible error that exists solely due to a weakness in our system.
We can fix all of this using a sum type:
interface Authenticated {
type: 'Authenticated'
user: User
data: SecureData
}
interface Unauthenticated { type: 'Unuthenticated' }
type AppState = Authenticated | Unauthenticated
const Routes = ({ state }: { state: AppState }) => {
switch(state.type) {
case 'Authenticated':
return (
<Authenticated
user={state.user}
data={state.data}
/>
)
case 'Unauthenticated':
return (
<Unauthenticated/>
)
}
}
We know at compile time that we can't display our 'authenticated' route unless we have both a User
and SecureData
. We can't misspell or forget to handle our routes due to compile-time exhaustiveness checking.
Illegal states are unrepresentable. Errors are pushed to the boundaries of the system. This is the meaning of the mantra "parse, don't validate"
Routing is a great example of event handling that redux-observable
handles well.
const epic = (action$: r.Observable<Action>): r.Observable<Action> => pipe(
r.fromEvent(window, 'popstate'),
ro.map(() => window.location.href),
ro.flatMap((route: string): Observable<Authenticated | undefined> => {
switch(route) {
case '/authenticated':
return r.from(loadUserAndData())
default:
return r.from({ type: 'Unauthenticated' })
}
}),
ro.map((appState: AppState): Action => ({
type: 'SetAppState',
appState,
}))
)
For more on parsing a route string
to a sum type, check out my fp-ts-routing tutorial.
Sum Type Utility
@morphic-ts/adt
is a great library that provides predicates and a reducer for free.
Let's check out an earlier example with a morphic upgrade:
import { makeADT, ofType, ADTType } from '@morphic-ts/adt'
interface FetchData { type: 'FetchData' }
interface UpdateData { type: 'UpdateData'; data: Data }
const Action = makeADT('type')({
FetchData: ofType<FetchData>(),
UpdateData: ofType<UpdateData>()
})
type Action = ADTType<typeof Action>
const epic = (action$: r.Observable<Action>): r.Observable<Action> => pipe(
action$,
ro.filter(Action.is.FetchData),
ro.map((action: FetchData) => ...),
)
const defaultState: AppState = ...
const reducer = Action.createPartialReducer(defaultState)({
UpdateData: (action: Action) => (state: AppState) => ...,
})
// there's also a curried pattern match
const AppState = makeADT('type')(...)
type AppState = ADTType<typeof AppState>
const Routes = ({ state }: { state: AppState }) => AppState.matchStrict({
Authenticated: ({ user, data }) => (
<Authenticated
user={user}
data={data}
/>
),
Unauthenticated: () => (
<Unauthenticated/>
)
})(state)
This replaces typesafe-actions, redux-actions, and ofType
in both ngrx and redux-observable by solving the more general problem of sum types.
Conclusion
redux-observable
is a powerful framework. It effectively decouples the ui of a project from it's behaviors, and guides it toward a more functional style. Though tangibly it mostly just helps implement undo & testing, it has benefits beyond that.
I like to use it if I'm working with streams or a lot of event handling. This is because I know that, especially if I need to mock out certain behaviors for testing, I will eventually end up architecting the project using something similar to redux-observable
anyway.
The framework itself is extremely simple and minimal. I use it less for its functionality than for its name - it tells incoming developers a lot about how the project is structured just by reading through its dependencies in package.json
.
redux-observable
can also be used to bring a pure functional framework to react
. For more on how redux-observable
can be used as an IO
entry point, check out my article about it. For more on the history of frontend functional frameworks, check out my article Why is redux-observable like that?
Hopefully this article gives a deep enough overview to decide whether or not redux
and redux-observable
is right for you!
-
This example's a bit misleading. We can't use
enum
withredux
becauseAction
has to be an object conforming to this interface:interface Action<T> { type: T }
↩enum
can't conform to that, since it's represented withnumber
values. I usedenum
for our example simply because I imagine more devs are familiar withenum
than with 'sum type' or 'union type', which are mostly the same thing.
Top comments (7)
Interesting article, thank you :-) I've been toying with the idea of using fp-ts but have resisted thus far just because it would take a good bit of investment to get good at it. I was also unsure of how it would work with react. This clarifies things a lot. Do you have any sample repos using these libs by any chance. I have worked with redux and some rxjs before so it might be a nice avenue into playing with fp-ts a bit.
Glad you liked it, thanks for reading!
I totally understand, fp can be a bit intimidating. If you're looking for a good starting point for fp-ts, I recommend Ryan Lee's 'Practical Guide to fp-ts': rlee.dev/writing/practical-guide-t...
I'm glad you commented, because I meant to include example code in this article but I forgot. Here's a link, I added it to the article too:
gist.github.com/anthonyjoeseph/746...
Hope this helps!
Brilliant thanks very much for this. It's a very different way of approaching redux from what the way I used to use it (more in it's raw form).
Yes I've started working through these tutorials. They look really good. I've got a good bit of experience with fp. It's more the typed fp stuff that I'm not familiar with, monads, monoids and all that stuff.
I can recommend Giulio Canti's (creator of fp-ts) Getting Started with fp-ts series for all of that stuff
Never tried it, but you can use Epics without redux:
github.com/BigAB/use-epic#epic
This is awesome! I wish it had Typescript types, I would use it otherwise
Add them maybe? :)