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
- The following snippets can be found in this sandbox.
We need a couple pieces that will stay the same throughout:
- A
NameInput
whose job is to allow a user to input a name and report to a parent component when that name is long - 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}
/>
);
}
🚨🚨🚨Red Alert! We got a stale state bug here!🚨🚨🚨
Here’s the sequence of events:
-
NameInput
callsonChange
followed byonLongName
-
effectFn
is called in that same render with the stalename
- 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}
/>
);
}
This attempt does 2 things:
- Creates a new boolean state property (
doEffect
) that gets set whenonLongName
is called - Uses
useEffect
to run theeffectFn
whendoEffect
istrue
Because useEffect
fires on a subsequent render we will have the fresh name
when effectFn
is called.
Get outta here stale state!
Pros
- ✅ Uses built-in React features
Cons
- ❌ Requires an extra piece of state (
doEffect
) - ❌ Requires an extra hook that can be tricky to use
- It’s easy to get dependencies wrong for
useEffect
- It’s easy to get dependencies wrong for
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" })}
/>
);
}
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:
-
Update
is used for simple state updates -
UpdateWithSideEffect
couples state changes with a function that has access to the updated state.
UpdateWithSideEffect
is precisely what we need here! Let’s zoom in:
UpdateWithSideEffect({ ...state }, ({ name }) => {
effectFn(name);
});
- (Ab)uses the fact that we can return the current state as the first argument
- Uses the second argument to ensure that we call
effectFn
with the freshname
Stale state be gone!
Pros
- ✅ Co-locates state changes with the effects that they trigger
Cons
- ❌ Introduces a new library with new patterns
- ❌ Requires using an action that does not produce any state changes (
onLongName
)
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)}
/>
);
}
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
- ✅ Familiar pattern of dispatching thunks
- ✅ No superfluous actions or state needed
Cons
- ❌ Adds extra complexity over a simple
useState
Fix That Bug
Ultimately we liked the trade-offs that the react-hook-thunk-reducer
library afforded:
- Avoids adding superfluous state properties or actions
- Familiarity with dispatching thunks from our usage of Redux
- Simplicity of the
react-hook-thunk-reducer
package itself - Using the reducer pattern has benefits outside of avoiding stale state
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!
-
- Couldn’t
NameInput
callonLongName
with thenewName
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
- Couldn’t