Making impossible React states impossible
If it's possible to represent states that should be impossible… please, at least consider making the possible impossible.
Consider the following situation, focusing in how the component state is structured:
function TaskList() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [isLoading, setLoading] = React.useState(true);
const [error, setError] = React.useState<unknown>(null);
React.useEffect(function loadTasks() {
fetchApi<Task[]>('/tasks')
.then((tasks) => {
setTasks(tasks);
setLoading(false);
setError(null);
})
.catch((error) => {
setError(error);
setLoading(false);
setTasks([]);
});
}, []);
if (error) {
return <p>Error loading tasks</p>;
}
if (isLoading) {
return <p>Loading…</p>;
}
if (tasks.length === 0) {
return <p>There are no tasks</p>;
}
return (
<ul>
{tasks.map((task) => (
<li>{task.content}</li>
))}
</ul>
);
}
In particular, see how we have to set several pieces of state at the same time.
setTasks(tasks);
setLoading(false);
setError(null);
There's even another potential problem. The way the state is structured is ambiguous how we should interpret it. Specifically, around the two flags isLoading
and error
. What happens if isLoading
and error
are true? Are we loading data, or did we receive an error?
You might think that it does not matter because the component makes sure to set/unset all flags in all the expected flows. But it could lead to confusion if we refactor things and forget to cover all our bases.
What if we designed this component's state so that it is really unambiguous what the state is at each time, by design, no matter what. Let's see how that would look like.
A single piece of compound state
We can use TypeScript's union types to our advantage, and only have certain data available when it makes sense.
type State =
| { status: 'loading' }
| { status: 'success'; tasks: Task[] }
| { status: 'error'; error: unknown };
function TaskList() {
const [state, setState] = React.useState<State>({
status: 'loading',
});
React.useEffect(function loadTasks() {
fetchApi<Task[]>('/tasks')
.then((tasks) => {
setState({ status: 'success', tasks });
})
.catch((error) => {
setState({ status: 'error', error });
});
}, []);
if (state.status === 'error') {
return <p>Error loading tasks</p>;
}
if (state.status === 'loading') {
return <p>Loading…</p>;
}
if (state.tasks.length === 0) {
return <p>There are no tasks</p>;
}
return (
<ul>
{state.tasks.map((task) => (
<li>{task.content}</li>
))}
</ul>
);
}
See how we only have tasks available if the data loading was successful, or the error object if the loading failed. Moreover, this ensures right from the start that we cover all cases. In a component like the one above, you would not be able to write the happy path (the final return) if you have not yet covered the error
and loading
states first. TypeScript won't let you even access the tasks array.
Other alternatives
This is not the end of the story. The transition to the pattern described above hints at other forms of state management that are often the next step, required in even more complex cases.
useReducer
The first one is switching to useReducer
. I do not think the case described above requires it, but for the sake of argument, let's explore how the code above would look like:
type State =
| { status: 'loading' }
| { status: 'success', tasks: Task[] }
| { status: 'error', error: unknown }
type Action
| { type: 'success', tasks: Task[] }
| { type: 'error', error: unknown }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'success':
return { status: 'success', tasks: action.tasks }
case 'error':
return { status: 'error', error: action.error }
default:
return state
}
}
function TaskList() {
const [state, dispatch] = React.useReducer(reducer, { status: 'loading' })
React.useEffect(function loadTasks() {
fetchApi<Task[]>('/tasks')
.then((tasks) => {
dispatch({ type: 'success', tasks })
})
.catch((error) => {
dispatch({ type: 'error', error })
})
}, [])
// Code from this point on remains the same as before…
}
You can probably see already why I considered it overkill for this example. Here are a few reasons why:
- The calls to
dispatch
are very similar to when we usedsetState
, but the added complexity to make it work (thereducer
function andAction
type) are not worth it for the gains. - The reducer function itself is too basic. It basically forwards the action arguments to the new state it returns. There's no complex logic being encapsulated by it.
However, this serves as an illustration for other cases that might benefit from it. If you find yourself making more complex state calculations in the calls to setState
, or if you find yourself needing parts of the previous state when building the new state, then a reducer pattern would be more justified.
State machines
This is ultimately what we've been doing throughout this entire post: state machines. They are great at making impossible states impossible. So this last section barely adds something new. I won't go into much detail here, as this is a topic that could easily fill books, and I'm pretty unskilled at it.
However, it's good to know about it. For more complex cases, there are patterns and libraries that do a much better job at encoding state machines, the actions that change their internal state, validations before performing these transitions, better error management, etc. Even side effects are often better handled by dedicated state machines libraries than by using React's useEffect
with some piece of state in its dependencies.
To get you started, I can point you to xstate, one of the more popular state machine libraries out there.