UPDATE: This post was easily misunderstood. Let me offer a few clarifications.

  1. 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.
  2. 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.
  3. 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 avoid useState, 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.

Hand-drawn sketch of a cogwheel named state instead a thought cloud

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.

Hand-drawn sketch of the Redux logo with a crown on it

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.

Hand-drawn sketch of an illustration of data fetching libraries with a crown on it

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

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.

// 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.

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.

Hand-drawn sketch of an illustration of state with a crown on it
Prabashwara Seneviratne (bash)

Written by

Prabashwara Seneviratne (bash)

Lead frontend developer