Easier Asynchronous State Modelling in React Redux or Hooks

Introduction

Modelling state for asynchronous actions in React using Redux via thunks or sagas is verbose. It requires a lot of code. It also is easy to accidentally miss flipping one of the values and result in a wrong state that your UI then shows the wrong thing. Thankfully there are ways to use Algebraic Data Types to model this state that results in less code in your reducers, whether in Redux or Hooks’ useReducer, as well as in your components. Below we’ll show the 3 problems modelling using Objects can cause and how to solve them using ADT’s.

All code shown below is on Github. The masterbranch has the Object way of modelling, and the types branch has the ADT solutions.

Most of the post below is based on prior art from Making Impossible States Impossible by Richard Feldman, and Solving the Boolean Identity Crisis by Jeremy Fairbank. Like Redux, stolen from Elm.

Defining The Problem

When you load data from a remote data source, most likely from a REST based HTTP server, you’ll have 3 states: loading, error, and success. This means loading the data, the data failed to load because of an error, and the data successfully loaded.

Loading State

Loading state

Error State

Error state

Success State

Success state.

To slowly model the above into a finite state machine, you start with loading state in your reducer so your component knows “if loading, show the loading state, else don’t”. You default it to trueso your UI will be assumed to be waiting to load data.

const initialState = {
  isLoading: true
}

When the data loads, you can set the isLoadingto false and put your data on the Object:

const initialState = {
  isLoading: false
  , foods: [...]
}

However, for errors, you need to indicate that it IS an error, so you add another Boolean:

const initialState = {
  isLoading: false
  , foods: undefined
  , isError: true
}

For logging and for the UI, you need to show this Error so you then add it as well:

const initialState = {
  isLoading: false
  , foods: undefined
  , isError: true
  , error: new Error('b00m b00m 💣')
}

Problem #1: Lots of Verbose, Duplicate Code

Every single asynchronous action in your application has about the same 3 states and 4 properties in an Object to represent it. You then go to represent it in a basic reducer function. When using Redux or useReducer, you use reducer functions, which is the name for the function you put in a reducefunction, like Array.prototype.reduce or Lodash’ reduce. Below, “foods” is our reducer function:

const foods = (state=defaultState, action) => {
  switch(action.type) {
    case LOAD_FOODS:
      return {
        ...state
        , isLoading: true
      }
    case LOAD_FOODS_FAILURE:
      return {
        ...state
        , isLoading: false
        , isError: true
        , error: action.error
      }
    case LOAD_FOODS_SUCCESS:
      return {
        ...state
        , isLoading: false
        , foods: action.foods
  }
}

However, either through TDD, manual testing, or unit tests, you start to find out basic problems with your reducer function, and state machines in general: the transitions between states can lead to impossible states… and your UI freaks out… or doesn’t and hides the weirdness.

If we go from loading to error, that’s fine, our state is:

const state = {
  isLoading: false
  , foods: undefined
  , isError: true
  , error: new Error(...)
}

Problem #2: Impossible States

But if we then retry, we’ll go from loading to success which results in:

const state = {
  isLoading: false
  , foods: [...]
  , isError: true
  , error: new Error(...)
}

Wat. How do we have foodsand an isError set to true!?

Problem #3: Incorrect and Unclear UI Behavior

You know something’s wrong when your UI shows an error even though the Network Panel in Chrome shows a status code 200 for the request and the JSON data all looks good:

const FoodsView = props => {
    useEffect(() => {
        props.loadFoodsThunk()
    }, [])

    if(props.foods.isLoading) {
        return (<Loading />)
    }
    // WHY IS IT STOPPING HERE FOR A SUCCESS!?!😠
    if(props.foods.isError) {
        return (<Failed error={props.foods.error}/>)
    }

    return (
        <FoodsList foods={props.foods.foods} calories={props.calories} addFood={props.addFood} removeFood={props.removeFood} />
    )
}

You realize you need to be super explicit about state transitions to ensure you don’t accidentally end up in an impossible state like the above where you have a success and error at the same time. We’ll make our reducer way more verbose to ensure all the unit tests pass:

const foods = (state=defaultState, action) => {
    switch(action.type) {
        case LOAD_FOODS:
            return {
                ...state
                , isLoading: true
                , isError: false
                , error: undefined
                , foods: undefined
            }
        case LOAD_FOODS_FAILURE:
            return {
                ...state
                , isLoading: false
                , isError: true
                , error: action.error
                , foods: undefined
            }
        case LOAD_FOODS_SUCCESS:
            return {
                ...state
                , isLoading: false
                , isError: false
                , error: undefined
                , foods: action.foods
            }
        default:
            return state
    }
}

While cool in that it’s the same pattern, it can be monotonous to duplicate this easily in each of your async reducers that need it without copy pasta. Worse, this is just 3 states. In some cases, especially in Enterprise applications where you’re dealing with legacy code or services that just go down a lot because of technical debt or high traffic, you could be in an “unstable” state, similar to the Circuit Breaker Pattern. That’s ok and expected, but you can start to see how your Object grows, and it’s not always so obvious what state transition bugs could occur, so you make your reducer even more verbose.

In an Object Oriented (OOP) code base, we’d just wrap this in a class with methods to ensure safe transitions. This abstraction makes it easier to read and we can test the class in isolation. That won’t help the UI part above, though.

So what do we do in a Functional Codebase?

Solving The Problems

To prevent the duplicated code, ensure you can’t reach impossible states in an FP way, and make your UI more clear and easier to debug, we’ll use an Algebraic Data Type, sometimes called a Tagged Union, provided by Folktale. There are other libraries you can use for this such as Crocs and Sanctuary. If you want to learn more, I have a video Fun With Folktale which covers the basics at 26:55.

First, npm install folktale or yarn add folktale and let’s import it:

import { union } from 'folktale/adt/union'

We have a default initial state that looks like:

const defaultState = {
    isLoading: true
    , error: undefined
    , isError: false
    , foods: undefined
}

Let’s model that same data structure with a type in Folktale:

const FoodsState = union('FoodsState', {
    LoadingFoods() { return {} }
    , FoodsLoaded(foods) { return { foods } }
    , FoodsError(error) { return { error } }
})

Instead of 4 un-related, yet related, properties, we can see our 3 states clearly:

  1. LoadingFoods: loading the food data
  2. FoodsLoaded: the list is loaded, and the Array is inside
  3. FoodsError: the list failed to load, and the Error is inside

We can leave them in FoodsState, but let’s destructure them so it’s less to type:

const { LoadingFoods, FoodsLoaded, FoodsError } = FoodsState

Solving Problem #2: Many States is Now One State at a Time

Since there are 3 different Objects, we solve problem #2: You can only be in 1 state at a time. Let’s update our reducer’s function signature that has the defaultState:

const foods = (state=defaultState, action) => {

To show this:

const foods = (state=LoadingFoods(), action) => {

Way more clear than “defaultState” or “initialState”; it says exactly right there what state we’re starting in: Loading. Notice it can’t magically be FoodsLoaded or FoodsErrorat the same time like it could in our Object.

Solving Problem #1: Verbose Code is Now Less Code

Let’s take our massive reducer function switch statement:

switch(action.type) {
    case LOAD_FOODS:
        return {
            ...state
            , isLoading: true
            , isError: false
            , error: undefined
            , foods: undefined
        }
    case LOAD_FOODS_FAILURE:
        return {
            ...state
            , isLoading: false
            , isError: true
            , error: action.error
            , foods: undefined
        }
    case LOAD_FOODS_SUCCESS:
        return {
            ...state
            , isLoading: false
            , isError: false
            , error: undefined
            , foods: action.foods
        }
    default:
        return state
}

… and use our union types to write less code AND still ensure only 1 state at a time:

switch(action.type) {
    case LOAD_FOODS:
        return LoadingFoods()
    case LOAD_FOODS_FAILURE:
        return FoodsError(action.error)
    case LOAD_FOODS_SUCCESS:
        return FoodsLoaded(action.foods)
    default:
        return state
}

Who’s your buddy? WHO’S YOUR BUDDY!?

If the action.typeis loading, cool, we’re loading. If the type is failure, we return a failed type and put the error in side. Lastly, if we’re successful, we return a success with our data inside. 28 lines to 10.

Solving Problem #3: Unclear UI Showing Wrong State to More Clear, Deterministic, and Easier to Debug

Lastly, our UI can more clearly represent this state. Instead of the imperative, abort early and render based on our guess at what the Object means:

const FoodsView = props => {
    useEffect(() => {
        props.loadFoodsThunk()
    }, [])

    if(props.foods.isLoading) {
        return (<Loading />)
    }
    
    if(props.foods.isError) {
        return (<Failed error={props.foods.error}/>)
    }

    return (
        <FoodsList foods={props.foods.foods} calories={props.calories} addFood={props.addFood} removeFood={props.removeFood} />
    )
}

We can instead run a pure function against the type using matchWith:

const FoodsView = props => {
    useEffect(() => {
        props.loadFoodsThunk()
    }, [])

    return props.foods.matchWith({
        LoadingFoods: () =>
            (<Loading />)
        , FoodsError: ({ error }) =>
            (<Failed error={error} />)
        , FoodsLoaded: ({ foods }) =>
            (<FoodsList foods={foods} calories={props.calories} addFood={props.addFood} removeFood={props.removeFood} />)
    })
}

No need to inspect different Booleansand guess you did it in the right order, or have things break even if you did, you now have VERY clearly named states to match against using a pure function.

Conclusions

Modelling asynchronous data in Redux or the useReducer React Hook can result in states that are impossible, yet your UI goes there by accident. This results in verbose reducers that are a pain to maintain as the amount of fetch calls in your application grows. Unless you have good tests, it also will sometimes result in UI bugs if you accidentally don’t model a state just right with all the Boolean flags set to the correct values.

Using ADT’s, or tagged unions, we can ensure impossible states are impossible to get to, we can significantly reduce how much reducer code we write, and our UI render code becomes more clear, testable, and debuggable.

Et tu, Action Creators?

And before you ask, sadly no, you cannot use them for Action Creators. Redux and various Redux libraries expect Action Creators to be pure Objects, and the type property to a String. I suppose you could create your own middleware, thus drastically reducing your reducer size and they’d become map functions vs. reducer functions. But at that point, you’re better off using Elm, heh!

Can Strong Typing Help Here?

On that note, be aware that TypeScript and Flow, currently, cannot detect if you’ve handled all match cases. For example, if you do this in your UI:

return props.foods.matchWith({
    LoadingFoods: () =>
        (<Loading />)
    , FoodsError: ({ error }) =>
        (<Failed error={error} />)
    , FoodsLoaed: ({ foods }) =>
        (<FoodsList foods={foods} calories={props.calories} addFood={props.addFood} removeFood={props.removeFood} />)
})

Notice we misspelled FoodsLoadedto FoodsLoaed. The compiler in Elm would catch that and not compile, but TypeScript and Flow will not. This will result in a runtime exception. The pattern matching proposal for JavaScript is at Stage 1 at the time of this writing so until that solidifies, if you’re a TypeScript fan, they have better ways of doing pattern matching the TypeScript way which’ll give you ADT’s + strong typing guarantees. It’s too verbose for my taste, though.

Leave a Reply

Your email address will not be published. Required fields are marked *