DEV Community

Anthony G
Anthony G

Posted on • Edited on

Should I use redux-observable? Also what is it? Also let's be honest what's redux?

merging streams

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?

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:

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 = ...
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 ???
  }
}
Enter fullscreen mode Exit fullscreen mode

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
// ...
Enter fullscreen mode Exit fullscreen mode

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> = ...
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  ...
}
Enter fullscreen mode Exit fullscreen mode

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 })),
)
Enter fullscreen mode Exit fullscreen mode

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>
  </>
)
Enter fullscreen mode Exit fullscreen mode

We have a few potential errors here:

  • What do we display we're at '/authenticated' but we have no user or no data?
  • 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>
  </>
)
Enter fullscreen mode Exit fullscreen mode

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/>
      )
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  }))
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!


  1. This example's a bit misleading. We can't use enum with redux because Action has to be an object conforming to this interface: interface Action<T> { type: T } 

    enum can't conform to that, since it's represented with number values. I used enum for our example simply because I imagine more devs are familiar with enum than with 'sum type' or 'union type', which are mostly the same thing.

Top comments (7)

Collapse
 
allforabit profile image
allforabit

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.

Collapse
 
anthonyjoeseph profile image
Anthony G

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!

Collapse
 
allforabit profile image
allforabit

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.

Thread Thread
 
anthonyjoeseph profile image
Anthony G

I can recommend Giulio Canti's (creator of fp-ts) Getting Started with fp-ts series for all of that stuff

Collapse
 
johannes5 profile image
Johannes5

Never tried it, but you can use Epics without redux:
github.com/BigAB/use-epic#epic

Collapse
 
anthonyjoeseph profile image
Anthony G

This is awesome! I wish it had Typescript types, I would use it otherwise

Collapse
 
johannes5 profile image
Johannes5

Add them maybe? :)