A slice of state management in react

State management in react

We're given a lot of choice of state management, but what's the best choice for your app? I am looking into state libraries and wanted to see their apis all in one location. There's not one best answer but I'll share my thoughts on the different options.

A view of app state

Most apps I've worked with typically look like this in terms of state:

Async and form state

Before we dive in, it's worth mentioning that most complex state in apps I've worked with are typically async data caches or form data. For this reason, in my experience I've rarely had to reach for global state libraries beyond context because minimal global state means minimal re-renders.

For async choose one of

  • trpc (if you control the client and the server)
  • apollo/urql (if you can use graphql)
  • @tanstack/react-query (if you're using REST based apis see tkdodo's blog post on why you want react-query)

For form libraries I've been happy with react-hook-form and zod for validation. I've liked that the zod adapter is built in and it minimizes re-renders if only the components that need to watch data watch for it.

Comparing all of these would be it's own post. So I'll leave it that starting with an async lib and sharing component state with a form lib can go a very long way.

Only when async, form and local state start to fall over, reach for a solution below.

General library questions

Perhaps what can help deciding on a library for yourself is answering some questions. Think about these as we look at each of the libraries and hopefully can inform your decision when a new hot state management library comes along.

  • Do I even need this library (ie. do I even need global state or can I get by with alternatives of something like react-hook-form and react-query)
  • Does one api map more closely to your current way of working or the way you want to work?
  • Which api is simplest? What new concepts to developers need to learn as they onboard?
  • Which features do I really need (pub/sub, change logs, developer tools)?
  • What would happen if I had to change the this library? Am I ok with tying my app to this library for the foreseeable future? How easy would it be to migrate off of? How long would it take to migrate what I have to a new library? When will I get the benefit?
  • How do I feel about reading the docs? How often do I even need to reference the docs while developing a proof of concept application?
  • After trying this in a test project, does that help answer some of the above questions? How easy was it to get started? How easy was it to make changes?
  • What are other people saying about the good/the bad/the great? Is the twitterverse cursing the library or is it a library that people are excited about?
  • Will the team behind the library be around for the foreseeable future? How well does it interop with other libraries?
  • How responsive has the dev team been to changes in react versions over time? How active are the commits, prs, issues?
  • How easy is it to test the store and store actions?
  • What are the impacts on bundle size?
  • Are SSR/SSG important and how does the library handle it?
  • How readable is the code if you actually had to fix a bug yourself?

What to look at in each library

Global state specific questions to think about:

  • how to create a global store?
  • how to create a selector (a slice of state)?
  • how to create actions/methods that change state?
  • how to read the store?
  • how to get react to subscribe to changes if anything specific needs to be added to the component (oberver, top level context, middleware, providers)?
  • how to manage pub/sub?
  • what mental model is needed to be adapted to use the library?
  • what does async look like if we need to manage async state updates?

Disclaimer

These are not optimal solutions, they're just what I managed to glean from the docs and examples and it's almost guaranteed that I've made a mistake somewhere. Hopefully even that is an indicator of how easy it was to follow the docs for their best practices.

Context

Context is fine for small apps and components where the shared state changes infrequently to reduce prop drilling. If state changes frequently such as an slider, or color picker then it's better to use a state management library like react-hook-form (yes it's a state library), recoil, zustand, jotai, valtio or a form library. If it's just to reduce re-renders use-context-selector could help.

Here's the a quick context setup example for the times when you need to share state between components. Inspired by the react context example by kentcdodds.


import {
useContext,
createContext,
useState,
SetStateAction,
Dispatch,
} from 'react'
type ExampleContextData = {
isActive?: boolean
setIsActive?: Dispatch<SetStateAction<boolean>>
}
const ExampleContext = createContext<ExampleContextData>({})
function useExampleContext() {
const context = useContext(ExampleContext)
if (context === undefined) {
throw new Error('Context must be used within a provider')
}
return context
}
function ExampleContextComponent() {
const { isActive, setIsActive } = useExampleContext()
return (
<button type="button" onClick={() => setIsActive((isActive) => !isActive)}>
Click to toggle {isActive ? 'active' : 'inactive'}
</button>
)
}
export function ContextExample() {
const [isActive, setIsActive] = useState(false)
return (
<ExampleContext.Provider value={{ isActive, setIsActive }}>
{/* Component value consumer */}
<ExampleContext.Consumer>
{(value) => <div>{value.isActive}</div>}
</ExampleContext.Consumer>
{/* Custom component */}
<ExampleContextComponent />
</ExampleContext.Provider>
)
}

Redux with Redux Toolkit (RTK)

It's been a long time since looking at redux. Many years ago I tried useReducer and found the over-rendering to be frustrating and threw everything to do with reducers away . It was still a lot of boilerplate to use redux and moving to a form library + react-query removed 99% of the need for complex global state.

RTK solves a lot of the things I didn't like. It's a lot easier to use and has much less boilerplate and actually lines up closer to some of the other state management libraries once the store is configured. There's still some steps when setting up a new slice. I like how immer is pre-bundled and the user doesn't need to necessarily do anything special to work with immutable state updates.

Edit 2023-09 - Given the amount of boilerplate and the wide adoption of react-query it seems hard to recommend rtk query anymore. Removing the temptation to use redux as the global async store is another reason to keep the separation of concerns clean.


// setup the store.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer,
},
})
export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>
// implement the reducer
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { RootState, AppThunk } from '../../app/store'
import { fetchCount } from './counterAPI'
export interface CounterState {
value: number
}
const initialState: CounterState = {
value: 0,
}
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
})
export const { increment, incrementByAmount } = counterSlice.actions
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they're used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value
// this is not required but perhaps is a way to access methods on store values
// at first glance it seems like a lot of boilerplate to first grab the dispatcher
// but as someone not used to redux always having to dispatch feels like extra work
// this has to be a hook so that we can grab the dispatch ourselves
export function useIncrementByAmount() {
const dispatch = useAppDispatch()
return {
incrementByAmount: ({ amount }: { amount: number }) =>
dispatch(incrementByAmount(amount)),
}
}
export default counterSlice.reducer
// Counter.tsx
// use it in a component
import React, { useState } from 'react'
import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { decrement, increment, selectCount } from '@/store/counterSlice'
export function Counter() {
const count = useAppSelector(selectCount)
const dispatch = useAppDispatch()
return (
<div>
<div>
<span className={styles.value}>{count}</span>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
increment
</button>
</div>
</div>
)
}

Zustand

Zustand is a small state management library.

The zustand count 0

import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import produce from 'immer' // not required but simplifies state updates
import { Button } from '@/components/Button'
interface CountState {
count: number
increaseCount: ({ amount }: { amount?: number }) => void
resetCount: () => void
}
const useCountStore = create(
subscribeWithSelector<CountState>((set) => ({
count: 0,
increaseCount: ({ amount = 1 } = {}) =>
set(
produce((draft) => {
draft.count += amount
})
),
resetCount: () =>
set(
produce((draft) => {
draft.count = 0
})
),
}))
)
// just a hook to get the count, probably overkill here but I've gotten used to
// writing react query hooks for all slices of global state
// this could decouple the component from the store and limits changes to just the "query" functions
function useGetCount() {
return useCountStore((state) => state.count)
}
export function ZustandCounter() {
const count = useCountStore((state) => state.count)
return <div>The zustand count {count}</div>
}
export function ZustandExample() {
const increaseCount = useCountStore((state) => state.increaseCount)
const resetCount = useCountStore((state) => state.resetCount)
return (
<>
<div className="flex items-center gap-2">
<Button variant="primary" onClick={() => increaseCount({ amount: 1 })}>
increment
</Button>
<Button variant="secondary" onClick={() => resetCount()}>
reset
</Button>
<ZustandCounter />
</div>
</>
)
}
// all state changes
// const unsubAll = useCountStore.subscribe(console.log)
// with middleware we can watch only the count changes
const unsubCount = useCountStore.subscribe(
(state) => state.count,
(count) => {
console.log('count changed to', count)
}
)

Immer simplifies updating state but even better is the immer middleware which reduces the boilerplate further. Just remember to follow the rules of immer.


import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
type State = {
count: number
}
type Actions = {
increaseCount: ({ amount }?: { amount?: number }) => void
resetCount: () => void
}
export const useCountStore = create(
immer<State & Actions>((set) => ({
count: 0,
increment: ({ amount = 1 } = {}) =>
set((state) => {
state.count += qty
}),
resetCount: () =>
set((state) => {
state.count = 0
}),
}))
)

To draw some analagoes to redux, zustand has actions that update the store via the set. What RTK has is similar now with state and dispatchers co-located. Co-location of state and actions makes it easier to reason about.

It's still a bit strange that the Actions needed to be typed when they look like they could be inferred from the initial store state. Taking the action from the slice of the store takes some getting used to, but if one thinks of it more like a class that has it's methods on it it seems less foreign and helps my mental model of how to use it and wrapping common methods in hooks might be another way to use them directly without having to grab a slice of the store each time.


const resetCount = useCountStore((state) => state.resetCount)

Jotai

Jotai is an state management library similar to recoil in that slices of state are treated as "atoms". As an initial user of this what takes getting used to is composing and using atoms and adding atom methods.

From the jotai docs:

Analogy Jotai is like Recoil. Zustand is like Redux.

When to use which

  • If you want a simple module state, Zustand fits well.

  • If you prefer Redux devtools, Zustand is good to go.

  • If you need a replacement for useState+useContext, Jotai fits well.

  • If you want to make use of Suspense, Jotai is the one.

  • If code splitting is important, Jotai should perform well.

1

import { Button } from '@/components/Button'
import { atom, useAtom, createStore, Provider } from 'jotai'
const countAtom = atom(0)
const messageAtom = atom('hello')
const myStore = createStore()
const initialCountState = 1
myStore.set(countAtom, initialCountState)
const unsub = myStore.sub(countAtom, () => {
console.log('Jotai countAtom value is changed to', myStore.get(countAtom))
})
function JotaiCounter() {
const [value, setValue] = useAtom(countAtom)
return (
<div className="flex items-center gap-2">
<Button onClick={() => setValue(value + 1)}>Increment</Button>
<Button onClick={() => setValue(initialCountState)}>Reset</Button>
<div>{value}</div>
</div>
)
}
export function JotaiExample() {
return (
<Provider store={myStore}>
<JotaiCounter />
</Provider>
)
}

Nice things

  • the provider is optional, which allows overriding context for things like components (zustand also now has this)
  • one can think about things just at the atom level and not as much a single store

Things to get used to

  • action mutations are done against atoms when the setValue isn't used. This could get harder to trace where things are changing but setValue in a form is similar so maybe is fine for smaller primitive changes but something such as calculating a total based on multiple things may require a composite action atom.

The syntax for an "action atom" looks a bit strange initially.


const incrementCountAtom = atom(
(get) => get(countAtom),
(get, set) => set(countAtom, get(countAtom) + 1)
)
// component uses it as
const [count, incrementCount] = useAtom(incrementCountAtom)
// alternative for just the setter
const incrementCount = useSetAtom(incrementCountAtom)

A valtio example

Valtio feels more natural for those who don't mind mutating state directly.

0

import { Button } from '@/components/Button'
import { proxy, subscribe, useSnapshot } from 'valtio'
import { subscribeKey } from 'valtio/utils'
export const state = proxy({
count: 0,
text: 'hello',
inc() {
++this.count // or see below for exporting directly from the module
},
})
// actions defined this way are better for code splitting
// https://github.com/pmndrs/valtio/blob/main/docs/how-tos/how-to-organize-actions.mdx#action-functions-defined-in-module
export function incrementCount({ amount }) {
state.count = state.count + amount
}
export function ValtioExample() {
const snap = useSnapshot(state)
return (
<div className="flex items-center gap-2">
<Button onClick={() => (state.count = state.count + 1)}>
Increment directly
</Button>
<Button onClick={() => incrementCount({ amount: 1 })}>
increment via function
</Button>
<Button onClick={() => (state.count = 0)}>reset</Button>
{snap.count}{' '}
</div>
)
}
// Subscribe to all state changes
const unsubscribe = subscribe(state, () =>
console.log('valtio example state has changed to', state)
)
// a primitive needs to be subscribed to with subscribeKey
const unsubscribeSlice = subscribeKey(state, 'count', () =>
console.log('valtio count state has changed to', state)
)

Legend state

Legend state is closeser to valtio and createSignal from solid (see below).


import { observable, observe } from '@legendapp/state'
import { useSelector, observer } from '@legendapp/state/react'
import { useTraceUpdates } from '@legendapp/state/trace'
import { useTraceListeners } from '@legendapp/state/trace'
const countStore = observable({
count: 0,
incrementAmount: 1,
})
// as a method in a module or mutate state directly
function inc() {
countStore.count.set((count) => count + 1)
}
const CounterObserver = observer(function CounterObserver() {
// some debugging tools
useTraceUpdates()
useTraceListeners()
return <div>Count: {countStore.count.get()}</div>
})
//
function CountSelector() {
const count = useSelector(() => countStore.count)
// Only re-renders if the return value changes
return <div>Watching the count with a selector: {count}</div>
}
function Counter() {
return (
<>
<button
onClick={() => {
countStore.count.set((count) => count + 1)
}}
>
increment
</button>
<button onClick={() => inc()}>inc</button>
<button onClick={() => countStore.count.set(0)}>reset</button>
<CounterObserver />
<CountSelector />
</>
)
}
function LegendStateExample() {
return (
<div className="App">
<Counter />
</div>
)
}
// observe re-runs when any observables change values
//
observe(() => {
console.log('watching for changes')
// if the primitive doesn't change, this oberver will not fire
console.log(countStore.count.get())
})
export default LegendStateExample

Super cool things about this lib are what aren't shown here, the built in performance where observables are automatically turned into react components(!!) that only re-render when the value changes. :mindblown:


import { enableLegendStateReact } from '@legendapp/state/react'
enableLegendStateReact()
const count = observable(0)
function Optimized() {
// This never re-renders when observable is rendered directly
return <div>Count: {count}</div>
}

Also their custom performance components such as Memo which automatically memoizes the children is pretty cool. Without this, the parent would typically re-render when the child changes.


function MemoExample() {
return (
<div>
<Memo>
{() =>
state.messages.map((message) => (
<div key={message.id}>{message.text}</div>
))
}
</Memo>
</div>
)
}

Signals and solid state

It's worth looking at how the solid js lib works by using signals. Performance is awesome and using signals as useState inside a component or outside as a store is really cool. Once a developer gets used to this way of working it's nice that performance comes naturally and doesn't require any observer tags since it's built into the framework. A feature I am super envious of is also declaring effects without providing their dependencies explicitly.


import { render } from 'solid-js/web'
import { createSignal } from 'solid-js'
const [countStore, setCountStore] = createSignal({
count: 0,
incrementAmount: 1,
})
function Counter() {
createEffect(() => {
console.log('The count is now', countStore().count)
}) // no dependency array! similar to observe but nice to see it built in
return (
<button
onClick={() =>
setCountStore((state) => {
console.info(state.incrementAmount)
return {
...state,
count: state.count + state.incrementAmount,
}
})
}
>
Count: {countStore().count}
</button>
)
}
render(() => <Counter />, document.getElementById('app'))

Like most proxy based solutions, one has to learn the differences between when to access the property directly and when to use a method based version.


countStore().count

This is not a big deal but is a learning curve and can lead to some harder to debug issues when first getting used to them.

Another example from tanstack-query with solid.


const [enabled, setEnabled] = createSignal(false)
const query = createQuery(() => ['todos'], fetchTodos, {
// ❌ passing a signal directly is not reactive
// enabled: enabled(),
// ✅ passing a function that returns a signal is reactive
get enabled() {
return enabled()
},
})

I imagine that once these are learned they get easier to use and seeing that signals are becoming popular across the libraries means this is definitely a space to watch.

Conclusion

Overall what I recommend is -- minimize global state so you hopefully don't need a lib. All jokes aside, it's not so much which is best, but which is best for your use case. One of the beautiful things is that the performance is pretty good if the recommendations of the libraries are followed and changes in global state aren't fired constantly.

If I had to choose just one in a vacuum without knowing the answers to the above questions.. drumroll.. Zustand with immer -- this is incredibly biased from my perspective and can and should differ for you and your project and may even differ by project (work or personal). Writing out some of my answers:

  • Do I even need this library (ie. do I even need global state or can I get by with alternatives of something like react-hook-form and react-query)

    probably not but only for a small bit of context

  • Does one api map more closely to your current way of working or the way you want to work?

    colocating state and actions is simple, I don't love the actions having to be sliced off of the store in each component but wrapping in a hook is a nice way to get around that

  • Which api is simplest? What new concepts to developers need to learn as they onboard?

    very small api, some of the other libraries work with proxies which are great but often have a few gotchas to learn about them. Things like when to use a function value(), when to access the store directly, and when to observe. It's true that zustand with immer (zimmer?) is using proxies but I've learnt many of the gotchas from using immer directly so for my use case this is a lower barrier to entry. Using mobx in the past was great but I did find myself with proxies when I was expecting the raw object and there was a lot of time unwrapping things. I'd like to give valtio and legend state another try in another project to see if I can get over the hump of learning the new api.

    I really like the idea of signals and truly reactive state changes so it may be that for more reactive applications with minimal boilerplate so one of those may help. It might be that working with proxy state is not as hard but would like to test this more before committing to it. Legend state is pushing the boundaries here so I'd like to give it a longer test drive than I was able to in a few hours.

  • Which features do I really need (pub/sub, change logs, developer tools)?

    has all of those, and a small bundle size is nice

  • What would happen if I had to change the this library? Am I ok with tying my app to this library for the foreseeable future? How easy would it be to migrate off of?

    hopefully the small api means this is easish to adatpt to a new library if needed

  • How do I feel about reading the docs? How often do I even need to reference the docs after I get started?

    docs are nice

  • After trying this in a test project, does that help answer some of the above questions? How easy was it to get started? How easy was it to make changes?

    was quick to get running once immer example was used, pretty easy to add a new action. Types are the biggest pain to have to duplicate type, but perhaps there is a better way to infer types from the initial store state. Needs some investigation but not a deal breaker

  • What are other people saying about the good/the bad/the great? Is twitter cursing the library or is it a library that people are excited about?

    anecdotal but most people seem happy with it on the twitters and haven't seen too many complaints

  • Will the team behind the library be around for the foreseeable future? How well does it interop with other libraries?

    pmndrs and dai-shi have great libs (including the aformentioned valtio, jotai), zustand used by 85k, and 158 contribs seem like it is healthy

  • How easy is it to test the store and store actions?

    👍🏻 store can be accessed directly so not much setup

State management has come a long way in recent years and as react developers we have almost too much choice for good libraries! Good luck in your state management choice, I hope this helps you make the right choice for your project.

Props

  • rtk - for reducing the boilerplate of redux
  • zustand - for a small and simple api
  • jotai/recoil - nice way to work with small slices of state
  • mobx - the original reactive proxy based state management library (and immer for being useful in lots of places)
  • valtio - reactive mutable state for react
  • legend state - really pushing for minimal re-renders with nice performance components and cool tricks like components from observable state
  • useState - the workhorse of react state management
  • react-query and use-swr - for getting so much state out of our other state managers
  • react-hook-form - for making forms fun-ish again

Further reading