UPDATE: This post was easily misunderstood. Let me offer a few clarifications.
- I am not presenting a universal solution for managing state in this post. I am only describing how my understanding of state management has changed.
- Initially, several statements in this post accidentally implied that certain libraries were incapable of certain use cases. I have since removed the offending statements since they are not vital to this post.
- What I realized was that moving state operations (
setState
) and side effects (useEffect
) outside of render functions and custom hooks led to much nicer code. That said, I am not saying that you should avoiduseState
,useEffect
or that you must use a state management library. I only ask that you give this point some thought and apply it where you see fit.
Over the last 7 months, I tried 5 state management libraries (Zustand, Jotai, Valtio, MobX, XState) and 1 data fetching library (Tanstack query) in monthly posts like this one. During that time, I also used Redux toolkit and React Router (it's a data-fetching library now) on professional projects.
That's a lot of libraries and in this post, I will describe my realizations about managing state after working with all of them.
Prologue
If you are wondering how I ended up trying 6 different libraries over the last few months, you can check out this post. TLDR; I kept hearing jargon that I didn't understand and I wanted to see if any of these new libraries were any good. Here are my takeaways.
Takeaways
State management is not a big deal anymore
For most of my career, working with React meant working with class components and vanilla Redux. During that era of React, we built everything on top of Redux. Async actions? Write a "thunk" which will expose it as several different Redux actions. Form state? Put it in Redux. Data from the API? Put it in Redux. So state management libraries ruled that era of React apps. They were the core of the app and they affected its entire structure.
But things have changed. Now we have hook libraries that implement huge chunks of functionality. Data fetching libraries such as Tanstack query (previously React query) have become particularly popular. You could implement a big part of a React app's functionality using only these hooks. You could even skip state management libraries entirely since only a few pieces of state will be left to be managed.
The new norm for building React apps is to use several hook libraries, where each one of them handles a specific piece of functionality (e.g. date fetching, forms, global state). Using these hooks, we can build faster while taking advantage of the tested patterns and code that those hooks provide. But there is a catch.
Hooks push state operations into the render function
I starting seeing glimpses of this after refactoring Timo to use Tanstack query but I didn't realize it. It was only after I refactored a real app to use RTK query that I started to understand. Let's walk through this problem with Tanstack query's hooks but I think it applies to any hook library.
In a nutshell, Tanstack query has query hooks for reading data from an API and mutation hooks for writing data to it.
// Original: https://github.com/bashlk/timo/blob/main/packages/tanstack-query/src/routes/Entries/Entries.jsx
import { useMutation, useQuery } from '@tanstack/react-query';
import { listEntries, updateEntry, deleteEntry } from '@timo/common/api';
const Entries = () => {
// Hook for getting data from the API
const { data: entries, isFetching, isError, error, refetch } = useQuery({
queryKey: ['entries', params],
queryFn: () => listEntries(params)
});
// Hook for submitting data to the API
const { mutate: update, error: updateError, variables: updateVariables, isPending: isUpdating } = useMutation({
mutationFn: updateEntry,
onSuccess: () => {
refetch();
}
});
}
Using these hooks to display the result of a single query or run a single mutation is very straightforward. But often you need more custom behavior such as
- Making multiple queries to different endpoints
- Combining multiple query responses
- Using the response of one query as parameters to another query
- Making different queries under different conditions
- Sending edited query responses back to the server
- Triggering mutations when a specific state is entered
Some of this behavior can be implemented by customizing the Tanstack query hooks. But for the rest you are out of luck and you will be forced to use extra useState
or useEffect
hooks alongside these hooks to implement more custom behavior. In the past, this kind of custom logic was in a state management library construct (e.g. reducer). But now it is within the render function and that is a terrible place for them. Speaking of which,
The render function is a terrible place for working with state
I am now convinced that trying to work with state within the render function is the cause of a lot of ugly React code. If you are changing state values within a useEffect
hook, you will end up with code that is extremely brittle and unpredictable.
As more useEffect
hooks are added to a component, it becomes harder to understand and more prone to errors. I think this is due to several reasons.
- It is difficult to control when a
useEffect
hook runs. It is triggered when any item in the dependency array changes and that is often not a reliable signal. The problem only gets worse when there are multiple dependencies. useEffect
relies on shallow equality to check if an item in the dependency array has changed. This is hard to ensure within the render function since the reference to objects and functions declared within it change every time it re-renders. So accidentally introducing an unstable dependency into auseEffect
can break it.- When a state update is triggered in a
useEffect
, that component re-renders and theuseEffect
hook runs again. This is an endless loop and the only way to prevent it is to carefully maintain the dependency array and/or add extra checks within the hook. However, changing the behavior of the component can easily break these checks.
// A timer or anything around timed events is a great breeding ground for useEffect hell
// Original: https://github.com/bashlk/timo/blob/main/packages/common/components/Timer/Timer.jsx
import { useEffect, useState } from 'react';
const Timer = ({ value, active, onPaused }) => {
// The actual ticking of the timer is done here so that only this component
// re-renders as the timer value changes
const [currentValue, setCurrentValue] = useState(value);
// The state-ops are split into several useEffect hooks for clarity
// but it is still not clear or nice to work with
useEffect(() => {
let interval;
if (active) {
interval = setInterval(() => {
setCurrentValue((val) => val + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [active, value]);
useEffect(() => {
if (!active) {
onPaused(currentValue);
}
}, [active, currentValue, onPaused]);
useEffect(() => {
setCurrentValue(value);
}, [value]);
// Format duration to HH:MM:SS
const formattedValue = new Date(currentValue * 1000).toISOString().slice(11, 19);
return (
<div>{formattedValue}</div>
);
};
useEffect
hell often happens gradually. It starts with a single useEffect
hook that can feel manageable. But, as a component evolves and more functionality is added, it can become a monster. The useReducer
hook can help a bit in these cases. But there is a better way.
The bliss of not working with state in the render function
My first impression of Jotai was not very good. I still think it is tricky to use.
But I kept thinking about it in the months that followed. Moving state operations out of the render function led to components that were much nicer to work with. So when I got around to trying XState, I set myself the goal of using zero useState
and useEffect
hooks. To my surprise, I succeeded and it was the best I felt about working with React in a really long time. The components felt lean and clear compared to their hook-laden counterparts.
// The Entries components after refactoring to Tanstack query
// Original: https://github.com/bashlk/timo/blob/main/packages/tanstack-query/src/routes/Entries/Entries.jsx
const Entries = ({ history }) => {
const [params, setParams] = useState({
from: getDateString(firstDateOfMonth),
to: getDateString(lastDateOfMonth)
});
const { data: entries, isFetching, isError, error, refetch } = useQuery({
queryKey: ['entries', params],
queryFn: () => listEntries(params)
});
const statusMessage =
isFetching ? 'Loading...' :
isError ? error.message :
entries?.length === 0 ? 'No entries found' : null;
const { mutate: update, error: updateError, variables: updateVariables, isPending: isUpdating } = useMutation({
mutationFn: updateEntry,
onSuccess: () => {
refetch();
}
});
const updateStatus = updateError ? updateError.message : isUpdating ? 'Saving...' : null;
const { mutate: deleteEntryM, error: deleteError, variables: deleteVariables, isPending: isDeleting } = useMutation({
mutationFn: deleteEntry,
onSuccess: () => {
refetch();
}
});
const deleteStatus = deleteError ? deleteError.message : isDeleting ? 'Deleting...' : null;
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
setParams({
from: formData.get('from'),
to: formData.get('to')
});
};
const handleNewClick = () => {
history.push('./new');
};
return (
...
)
}
// The Entries components after refactoring to Jotai
// Original: https://github.com/bashlk/timo/blob/main/packages/jotai/src/routes/Entries/Entries.jsx
const Entries = () => {
const groupedEntries = useAtomValue(entriesGroupedByDateAtom);
const entriesDuration = useAtomValue(entriesDurationAtom);
const entriesCount = useAtomValue(entriesCountAtom);
const entriesDurationsGroupedByDate = useAtomValue(entriesDurationsGroupedByDateAtom);
const { isLoading, isError, error } = useAtomValue(entriesStatusAtom);
const statusMessage =
isLoading === 'loading' ? 'Loading...' :
isError ? error.message :
entriesCount === 0 ? 'No entries found' : null;
const { mutate: updateEntry, error: updateError, isPending: isUpdating, variables: updateVariables } = useAtomValue(updateEntryAtom);
const updateStatus = updateError ? updateError.message : isUpdating ? 'Saving...' : null;
const { mutate: deleteEntry, error: deleteError, isPending: isDeleting, variables: deleteVariables } = useAtomValue(deleteEntryAtom);
const deleteStatus = deleteError ? deleteError.message : isDeleting ? 'Deleting...' : null;
const [startDate, setStartDate] = useAtom(entriesStartDateAtom);
const [endDate, setEndDate] = useAtom(entriesEndDateAtom);
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
setStartDate(formData.get('from'));
setEndDate(formData.get('to'));
};
const setLocation = useSetAtom(baseAwareLocationAtom);
const handleNewClick = () => {
setLocation({ pathname: '/new' });
};
return (
...
)
}
// Entries component after refactoring to XState
// Original: https://github.com/bashlk/timo/blob/main/packages/xstate/src/routes/Entries/Entries.jsx
const Entries = () => {
const {
groupedEntries,
totalDuration,
statusMessage,
filter,
itemStatusMessage
} = useChildMachineState('entries', state => state.context);
const entriesMachine = useChildMachine('entries');
const rootMachine = useRootMachine();
const handleEdit = (updatedEntry) => {
entriesMachine.send({
type: 'updateEntry',
updatedEntry
});
};
const handleDelete = (entryId) => {
entriesMachine.send({
type: 'deleteEntry',
entryId
});
};
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
entriesMachine.send({
type: 'filter',
startDate: formData.get('from'),
endDate: formData.get('to')
});
};
const handleNewClick = () => {
rootMachine.send({
type: 'pushRoute',
route: 'newEntry'
});
};
return (
...
)
}
Writing "state op-free" components that only render state values and emit actions is not a new idea. Most state management libraries encourage us to do this. But as React hooks arrived, I think we forgot all about it. We were tired of writing boilerplate Redux code so we were happy to write less code for a change. But that code can easily turn into a mess.
Closing thoughts
All of this is a lot to take in. So if you take away only one thing from this post, let it be the totally not new idea of state op-free components, where components are put in charge of only rendering state values and emitting actions. The key is moving state operations out of the chaos of the render function and the useEffect
hook and onto stable ground. Here is how I think it will work for the libraries I tried.
- If you are able to use the query and mutation hooks without using any other hooks alongside them, then data fetching libraries (i.e. Tanstack query, RTK query) can allow you to build state op-free components. But I think you will probably need extra functionality on top of them and that is where things start to get ugly.
- Using a state management library alongside a data fetching library will still introduce state-ops into the component. So when you need custom functionality, the only way to have state op-free components is to implement that functionality using a state management library.
- Most state management libraries don't have higher-level functionality built-in. So if you want to do something like data-fetching, you have to implement it from scratch. However, most state management libraries have built-in functionality for handling async operations so that will make it easier.
- While RTK query is built on Redux, the underlying API slices can't be modified to the same degree as standard RTK slices. While RTK is much nicer to work with than vanilla Redux, implementing async operations and side effects is still awkward with thunks and middleware.
- Newer versions of React router (v6+) have a very nice way of lifting server operations outside of the components in the form of loaders and actions. But they seem best suited to handling page-level operations.
- Each member of the Poimandres trio (Zustand, Jotai, Valtio) expose a way to work with state outside of React components. But you need to be proactive in implementing state-ops and side effects outside the components since it is easier and more obvious to implement them within.
- Jotai has a growing ecosystem of extensions that allow higher-level functionality to be managed outside of the components. (e.g. jotai-tanstack-query, jotai-location) However, I had to poke around a lot to figure out how they work and how to use them well.
- XState provides a great way to build state op-free components but writing statecharts takes a lot of effort. It might be worth it though.
At the start of this post, I said state management is not a big deal since custom hooks manage it for us. But conversely, state management is more important now than ever when those hooks fall short.