State Management with React
João Vitor
Posted on September 7, 2022
TL;DR
This guide is a compilation of many YouTube videos and articles I watched and read about State management with ReactJS.
State management is very important in React applications because our projects are built around our choices on how to handle them.
Application State can be divided into UI State and Server State (cache). Those are very different from each other; therefore, we shouldn't use the same tools and techniques to handle each one.
Using a Server State management library to handle queries and mutations like React Query or SWR will eliminate most of the complexity in your code, leaving just a few UI states, like open and close modals, to manage with the built-in hooks from React and other techniques.
State Management with React
There are various categories of state, but every type of state can fall into one of two buckets:
- Server Cache - State that's actually stored on the server and stored in the client for quick access (like user data).
- UI State - State that's only useful in the UI for controlling the interactive parts of our app (like modal
isOpen
state).We make a mistake when we combine the two. Server cache has inherently different problems from the UI state and needs to be managed differently. If you embrace that what you have is not actually a state but is instead a cache of states, then you can start managing it correctly.
Kent C. Dodds, application state management with React.
UI State Management
React is a UI State Management library, and the best approach to handle UI state is using the built-in hook useState
.
1. useState (recommended)
Here's a Counter example to demonstrate some options for handling UI state using React.
function Counter() {
const [count, setCount] = React.useState(0)
const increment = () => setCount(c => c + 1)
return <button onClick={increment}>{count}</button>
}
function App() {
return (
<div>
<Counter />
</div>
)
}
Shareability
This is a common issue developers face when building React applications. Sharing the state between components is not always easy because components are often too complex.
But let's say your Product Manager wants you to display the current counter value to the user.
function CountDisplay() {
// Where do I get the {count} value?
return <p>The current counter value is {count}</p>
}
function App() {
return (
<div>
<CountDisplay />
<Counter />
</div>
)
}
There's no need for any state management library at this point, we can just share the state between the components via props by lifting the state to a common parent of Counter
and CountDisplay
(in our case, it's the App
component).
Lifting state
This might seem a little too basic but in the real world is hard to identify these opportunities because our components aren't always as simple as this one.
Here we're moving the state up from the Counter
component to the App
component and sharing it via props.
function CountDisplay({ count }) {
return <p>The current counter count is **{count}**</p>
}
function Counter({ count, onIncrement }) {
return <button onClick={onIncrement}>**{count}**</button>
}
function App() {
const [count, setCount] = React.useState(0)
const increment = () => setCount(c => c + 1)
return (
<div>
<CountDisplay />
<Counter count={count} onIncrement={increment} />
</div>
)
}
If you want to know more about where to colocate your state, "Thinking in React" is a good place to start.
Prop Drilling
Lifting state is a nice technique to share state with components but when you have to share something from a very high component in the component tree, using that technique will introduce something called Prop Drilling.
For example, imagine that our CountDisplay
component got too complex for whatever reason, and we decided to move the count message to a child component CountMessage
.
function CountMessage({ count }) {
return <p>The current counter count is {count}</p>
}
function CountDisplay({ count }) {
// ...very complex logic
return <div><CountMessage count={count} /></div>
}
function App() {
const [count, setCount] = React.useState(0)
const increment = () => setCount(c => c + 1)
return (
<div>
<CountDisplay />
<Counter count={count} onIncrement={increment} />
</div>
)
}
Prop drilling isn't necessarily bad, but using the Context API to solve this is not recommended.
Instead, we can leverage component compositions to avoid this issue.
Component Composition
Here's an example that demonstrates how we can avoid prop drilling by using composition:
function CountMessage({ count }) {
return <p>The current counter count is {count}</p>
}
function CountDisplay({ message }) {
// ...very complex logic
return <div>{message}</div>
}
function App() {
const [count, setCount] = React.useState(0)
const increment = () => setCount(c => c + 1)
return (
<div>
<CountDisplay message={<CountMessage count={count} />} />
<Counter count={count} onIncrement={increment} />
</div>
)
}
Passing the CountMessage
component as property allows us to share the count
state just one level down the component tree and remove prop drilling.
Michael Jackson, the co-founder of remix_run, has a wonderful youtube video about Using Composition in React to Avoid "Prop Drilling".
@mjackson:
Unpopular opinion: All that junk you're putting in React context should just be props. Legit context uses are rare, mostly for library code, not your app.
2. useContext (not so much recommended)
Before You Use Context
Context is primarily used when some data needs to be accessible by many components at different nesting levels. Apply it sparingly because it makes component reuse more difficult.
If you only want to avoid passing some props through many levels, the component composition is often a simpler solution than context.
https://reactjs.org/docs/context.html#before-you-use-context
Why is the Context API not so good?
The Context API is very powerful, but sometimes we use it for the wrong use cases.
When using it, we need to keep in mind a few things:
- Consumers are bound to their providers. This means you can't reuse consumers (Components that access the Context value) where the provider is unavailable.
- When the provider's value changes, all its consumers will re-render. That can lead to unintended performance issues.
- Use it when your provider value doesn't change too often, and you don't mind your consumers re-rendering when they do.
3. Other solutions to UI state management
If the React useState
hook is not enough to handle the UI state in your application, we need to know why it wasn't enough and then start looking at different solutions to deal with that use case.
Jotai and Zustand are good options nowadays, but they solve different problems.
When to use which:
- If you need a replacement for useState+useContext, Jotai fits well.
- If you want to update the state outside React, Zustand works better.
- If code splitting is important, Jotai should perform well.
- If you prefer Redux dev tools, Zustand is good to go.
- If you want to make use of Suspense, Jotai is the one.
Recoil is an alternative to Jotai.
If you want to know more, listen to Theo talk about the difference between Jotai and Zustand and how those alternatives differ from Redux.
Server State Management
Some libraries handle Server states very well and provide you with loading states, query and mutation helpers, optimistic updates with rollbacks and so much more:
- React Query;
- SWR;
- Apollo Client, only for those using GraphQL;
I haven't used those three enough to tell you which one is better, but here are my considerations about React Query and SWR:
React Query
- Library with awesome utilities for data fetching, mutation, optimistic updates, and more.
- Built-in developer tools
- Needs a Cache Provider
- More people using it, which means more examples on StackOverflow
Basic Example
const queryClient = new QueryClient()
function Component() {
const query = useQuery(['todos'], getTodos);
return (
// needs a Cache Provider
<QueryClientProvider client={queryClient}>
<TodoList posts={query.data} />
</QueryClientProvider>
)
}
function AddTodo() {
const mutation = useMutation(newTodo => axios.post('/todos', newTodo), {
onSuccess: () => {
// needs explicit invalidation
queryClient.invalidateQueries(['todos']);
}
});
return (
<button
onClick={() => {
mutation.mutate({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
);
}
SWR
- Lightweight library (9.9kB minified over 43.1kB minified from react-query) with awesome utilities for data fetching, mutation, optimistic updates, and more.
- Developer tools with external library
- It doesn't need a Cache Provider
- IMO it has a more straightforward implementation
Basic Example
function Component() {
const query = useSWR('todos', getTodos);
return (
// no Cache Provider or query client
<TodoList posts={query.data} />
)
}
function AddTodo() {
const { mutate } = useSWRConfig();
// Automatically invalidates queries by key
const addTodo = (newTodo) => mutate('todos', () => {
axios.post('/todos', newTodo);
});
return (
<button
onClick={() => {
addTodo({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
);
}
Conclusion
If we combine a Server State management library with the techniques mentioned in this article to manage UI State, we can elimate the need to handle server loading states ourselves, when fetching and mutating. Our React projects will become easier to maintain, implement new features, and test them.
Posted on September 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.