Skip to the content.

No One Likes Stale React State

by @z0isch on August 10, 2022

State management is a fiery topic! How it gets managed can be nuanced and it can be easy to make mistakes.

Millions of students use Freckle to practice their skills. We recently uncovered a bug that misrecorded some student answers due to stale state in React.

This post shows several ways to address stale state in React and the solution we picked to fix our bug.

React and useState

Here at Freckle we default to React’s built-in useState hook for our component level state needs. The useState hook is a simple way to manage state inside a React component.

Its simplicity comes with an important caveat that is outlined in the FAQ section, “Why am I seeing stale props or state inside my function?”:

Any function inside a component, including event handlers and effects, “sees” the props and state from the render it was created in.

In other words, it’s easy to act on stale state that will get updated in the next render.

This is precisely the issue that caused our bug. Let’s dig in with a contrived example that exhibits this behavior and some ways we can mitigate it.

Contrived Example

We need a couple pieces that will stay the same throughout:

  1. A NameInput whose job is to allow a user to input a name and report to a parent component when that name is long
  2. An effectFn that runs some sort of effectful computation (HTTP request, logging, etc.) with a given long name
const NameInput = ({ name, onChange, onLongName }) => (
  <input
    type="text"
    onChange={(e) => {
      const newName = e.target.value;
      onChange(newName);
      // Real long name you got there...
      if (newName.length > 1) {
        onLongName();
      }
    }}
    value={name}
  />
);

const effectFn = (longName) => {
  // Ahh, nice and contrived
  alert(longName);
}

The following are attempts to use this NameComponent and run the effectFn from a parent component when the name is long.

Attempt 1 - There’s no way this one is right

This seems straightforward enough: run the effectFn on the name in the state when onLongName gets called. What could go wrong here?!?

function App() {
  const [name, setName] = React.useState("");
  const onLongName = () => effectFn(name);
  return (
    <NameInput
      name={name}
      onChange={setName}
      onLongName={onLongName}
    />
  );
}

View sanbox

🚨🚨🚨Red Alert! We got a stale state bug here!🚨🚨🚨

Here’s the sequence of events:

  1. NameInput calls onChange followed by onLongName
  2. effectFn is called in that same render with the stale name
  3. A re-render happens with the fresh name

So what can we do about this?

Attempt 2 - The vanilla React solution

Okay—let’s try using a useEffect hook to run the effectFn:

function App() {
  const [name, setName] = React.useState("");
  const [doEffect, setDoEffect] = React.useState(false);

  React.useEffect(() => {
    if (doEffect) {
      effectFn(name);
      setDoEffect(false);
    }
  }, [name, doEffect]);

  const onLongName = () => {
    setDoEffect(true);
  };

  return (
    <NameInput
      name={name}
      onChange={setName}
      onLongName={onLongName}
    />
  );
}

View sanbox

This attempt does 2 things:

Because useEffect fires on a subsequent render we will have the fresh name when effectFn is called.

Get outta here stale state!

Pros

Cons

Seems like there are some downsides to this approach, let’s see what else we can come up with.

Attempt 3 - Getting a bit fancy

This attempt uses a neat library: use-reducer-with-side-effects. It lets a reducer function couple side effects with state changes. Let’s check it out!

import useReducerWithSideEffects, {
  UpdateWithSideEffect,
  Update
} from "use-reducer-with-side-effects";

function App() {
  const [state, dispatch] = useReducerWithSideEffects(
    (state, action) => {
      switch (action.tag) {
        case "updateName":
          return Update({
            ...state,
            name: action.name
          });
        case "onLongName":
          return UpdateWithSideEffect(
            { ...state },
            ({ name }) => {
              effectFn(name);
            }
          );
        default:
          return state;
      }
    },
    { name: "" }
  );

  return (
    <NameInput
      name={state.name}
      onChange={(name) =>
        dispatch({ tag: "updateName", name })
      }
      onLongName={() => dispatch({ tag: "onLongName" })}
    />
  );
}

View sanbox

We’ve switched to reducer-style state management instead of the useState hook. The reducer function passed to useReducerWithSideEffects expects the function to return either an Update or UpdateWithSideEffect value:

UpdateWithSideEffect is precisely what we need here! Let’s zoom in:

UpdateWithSideEffect({ ...state }, ({ name }) => {
  effectFn(name);
});

Stale state be gone!

Pros

Cons

Attempt 4 - Who’d a thunk it

At Freckle we also use Redux to help manage our truly global state. It has a concept of dispatching a thunk to allow access to the current state.

Is there a similar package in the community for dispatching a thunk at the component level?

Enter react-hook-thunk-reducer:

import { useThunkReducer } from "react-hook-thunk-reducer";

function App() {
  const [state, dispatch] = useThunkReducer(
    (state, action) => {
      switch (action.type) {
        case "updateName":
          return { ...state, name: action.name };
        default:
          return state;
      }
    },
    { name: "" }
  );

  const onLongName = (_dispatch, getState) => {
    const { name } = getState();
    effectFn(name);
  };

  return (
    <NameInput
      name={state.name}
      onChange={(name) =>
        dispatch({ type: "updateName", name })
      }
      onLongName={() => dispatch(onLongName)}
    />
  );
}

View sanbox

This again is using reducer-style state management, but this time instead of only dispatching actions, we can also dispatch functions! The key pieces are:

const onLongName = (_dispatch, getState) => {
  const { name } = getState();
  effectFn(name);
};

dispatch(onLongName);

onLongName now has access to getState which allows us to query the current state! This means that effectFn will be called with the fresh name.

You know what that means: SO LONG STALE STATE!

Pros

Cons

Fix That Bug

Ultimately we liked the trade-offs that the react-hook-thunk-reducer library afforded:

There are likely many other ways to solve this issue1 that were not explored here. Feel free to reach out if there are other compelling options in this space that were missed!

Footnotes

    • Couldn’t NameInput call onLongName with the newName that it has access to?
      • This is likely the best solution if it’s possible to do
      • In our case the state was a lot more complicated and was difficult to boil down to calling back with the correct data
    • The React docs mention using ref as an escape hatch for this, but mutable state gives us the willies if it’s not absolutely necessary