Complete Guide to State Management in React

Complete Guide to State Management in React

If you are writing a React application, you are almost certainly managing state somewhere. State is surprisingly hard to define, but easier to understand with examples:

  • An input box has state - which is often just the value in the input box.
  • A form has state - which is usually all the values in the form.
  • An application has state - which is all the values that it takes to render/operate the application.

Those are more conceptual examples of state. React components themselves will have their own state and will often pass state to their child components via props.

In this post, we’ll look at the common ways to manage client state - meaning state that is managine on the frontend. We’ll look at when you should use the different patterns as well as some example code snippets. We’ll save server/async state for a followup post.

Section 1: The basics

The most basic primitive: useState

For state management, we need a place to start, and one of the simplest primitives is useState.

useState is a React hook that manages a single value. It’ll return both the value itself, and a function you can call to update the value.

useState tends to shine in simple cases, where the state is used in at most a few components. Take this Menu example (from Mantine):

const Demo = () => {
    const [opened, setOpened] = useState(false);
    return <>
        <button onClick={() => setOpened(true)}>
            Open Menu
        </button>
        <Menu opened={opened} onChange={setOpened}>
            {/* Menu content */}
        </Menu>
    </>
}

You click the button, opened is set to true, the menu opens. It’s about as easy as it gets, but let’s look at when it starts to get a bit more complicated.

Complex state updates with useReducer

Not to diss booleans, but your application will almost certainly have more complicated interactions that require more complicated types.

Let’s take a big jump in complexity and imagine we are working at Redfin and we are building all the filters for searching for a house:

Ultimately, this entire form is designed to produce a query that will be sent to our backend. Here’s a simplified example of what our query object might look like:

type Query = {
    zipCode?: string,
    minPrice?: number,
    maxPrice?: number,
    homeTypes?: string[],
    requiredFeatures: {
        hasFireplace?: boolean,
        hasWasherDryler?: boolean,
        // ... etc
    },
    // ... etc
}

Now we need to create different components that manage each of these fields. One naive approach would be to use useState:

const HomeBuyingFilters = () => {
    const [query, setQuery] = useState(getDefaultFilterQuery())
    
    // TODO: fetch data for query
    
    return <div>
        <ZipCodeFilter query={query} setQuery={setQuery} />
        <PriceFilter query={query} setQuery={setQuery} />
        <HomeTypesFilter query={query} setQuery={setQuery} />
        <RequiredFeaturesFilter query={query} setQuery={setQuery} />
        <ResetAllFiltersButton setQuery={setQuery} />
    </div>
}

Each of our components can pull out the parts of query that are relevant for them, and they can make any changes they want to with setQuery.

This isn’t terrible, but it does feel odd to pass the full query to each component when they don’t need it (note that we’re going to ignore any performance implications of this, because that would make this post a lot longer).

Even worse, debugging this can be a pain. If we were tracking down a bug that someone reported like:

Sometimes zip code gets reset randomly? I’m not sure what I’m doing to trigger that

It’s hard to know the full scope of where we need to check. We actually need to check every call to setQuery as any of them could accidentally (or intentionally in the case of the ResetAllFiltersButton) modify the zip code.

So we have a brilliant idea: let’s make dedicated functions for each concrete state change. It could look like this:

const HomeBuyingFilters = () => {
    const [query, setQuery] = useState(getDefaultFilterQuery())
    
    // TODO: fetching data for query, we'll look more that this later
    
    const setZipCode = (zipCode: string) => {
        setQuery(query => { return { ...query, zipCode } })
    }
    
    const setMinPrice = (minPrice: number | undefined) => {
        setQuery(query => { return { ...query, minPrice } })
    }
    
    return <div>
        <ZipCodeFilter zipCode={query.zipCode} setZipCode={setZipCode} />
        <PriceFilter 
            minPrice={query.minPrice}
            maxPrice={query.maxPrice}
            setMinPrice={setMinPrice}
            setMaxPrice={setMaxPrice}
        />
        { /* and so on */ }
    </div>
}

This has some nice advantages - it’s way easier to audit all changes made to zipCode as they should all be contained in functions defined in HomeBuyingFilters. The ZipCodeFilter component also only ever needs to be exposed to zipCode and setZipCode, instead of needing to know the full query and how to update it.

However, there are two disadvantages that would be nice to fix:

  • It feels odd to put all our state updating logic inside one specific component.
  • For each new function we make, we have to hook up another prop to the filter(s) that need it.

This is where useReducer can come in handy. Let’s rewrite our example with it.

UseReducer example

The way to think about useReducer is there are 3 major pieces:

  • The state - in our case, this is our Query type
  • Actions - in our case, these are setZipCode and setMinPrice. In other words, these are triggers that may affect the state.
  • The reducer - this is a function that takes in both the state and an action, and returns an updated state.

Here’s our example from above, using this format:

// queryState.ts

// We'll use an enum for the different types of actions
export enum QueryActionType {
    SET_ZIP_CODE = 'SET_ZIP_CODE',
    SET_MIN_PRICE = 'SET_MIN_PRICE',
    // etc
}

// Actions describe the "changes" that can be made to the state,
// so in addition to the type, we also need data
export type QueryAction = {
    action: QueryActionType.SET_ZIP_CODE,
    zipCode: string,
} | {
    action: QueryActionType.SET_MIN_PRICE,
    minPrice: number | undefined,
}

// The reducer returns the latest state after applying the action
// The reducer must always create a new object, it cannot modify query
export function queryStateReducer(query: Query, action: QueryAction): Query {
    switch (action.action) {
        case QueryActionType.SET_ZIP_CODE:
            return { ...query, zipCode: action.zipCode }
        case QueryActionType.SET_MIN_PRICE:
            return { ...query, minPrice: action.minPrice }
        // etc
    }
}

We can hook this up in our filter with useReducer:

const HomeBuyingFilters = () => {
    const [query, dispatch] = useReducer(queryStateReducer, getDefaultFilterQuery())
    
    // TODO: fetching data for query, we'll look more that this later
    
    return <div>
        <ZipCodeFilter zipCode={query.zipCode} dispatch={dispatch} />
        <PriceFilter 
            minPrice={query.minPrice}
            maxPrice={query.maxPrice}
            dispatch={dispatch}
        />
        { /* and so on */ }
    </div>
}

And finally, we can call this from our components:

const ZipCodeFilter = ({zipCode, dispatch}) => {
    const setZipCode = (zipCode) => {
        // dispatch takes in one of our "Actions", which will automatically
        // apply the changes to the state defined in our reducer
        dispatch({
            action: QueryActionType.SET_ZIP_CODE,
            zipCode,
        })
    }
    
    // the rest
}

The nice thing here is our ZipCodeFilter is explicit with what it is doing - it’s dispatching the action SET_ZIP_CODE.

If you want to add a new action CLEAR_ZIP_CODE, your props don’t need to change. You just add the new action, update the reducer to show how that action affects the state, and then any component can trigger the CLEAR_ZIP_CODE action.

We also don’t have the same problem as before where we need to audit every call to dispatch for debugging, we only need to audit the relevant actions.

useReducer tends to shine when actions can have complicated updates to the state. The component doesn’t need to worry about updating the state itself, it can just fire off an action and subsequently get/render the updated state passed in to it.

This separation of concerns has other advantages - like the fact that you can test the reducer without needing to render any components.

It is a little annoying to have to pass around dispatch everywhere. In the “Global State” section, we’ll see how to handle cases where we have state that needs to be accessed/modified across potentially many layers of components.

An aside: Managing Form State

Since useState is great when your state exists in one component, you might be tempted to use it for forms, like so:

export default function Form() {
  const [name, setName] = useState('Taylor');

  const handleSubmit = async (e) => {
    e.preventDefault()
    await updateUserInformation({name})
  }

  return <form onSubmit={handleSubmit}>
    <input
      value={name}
      onChange={e => setName(e.target.value)}
    />
    <button type="submit">Update</button>
  </form>
}

This is totally reasonable, especially for small forms. But this example is a little overly simplified, as it doesn’t include important things like errors or a loading state while the API call is being made.

Helpful forms will also have features like preventing the user from leaving the page if they haven’t saved yet, which means you need to track the last persisted form state.

Instead of managing it all with a bunch of useStates, you can instead reach for a library that specializes in forms like Mantine’s use-form or React Hook Form. These libraries can significantly reduce the amount of boilerplate you need to write.

At PropelAuth, we turned this code snippet into a hook called useConfirmRedirectIfDirty. Combining that with Mantine’s use-form, warning users about unsaved changes in our dashboard looks like this:

const form = useForm({
    initialValues: { ... },
})

useConfirmRedirectIfDirty(form.isDirty())

// This will manage state, errors, and isDirty for us
<TextInput label={"Name"} {...form.getInputProps("name")} />

Section 2: Global State

Forms and menus are great, but what about a global dark mode setting? Or Stripe’s test mode toggle? Or a deeply nested tree of React components, where the state at the top needs to be accessed and modified by many of the child components?

We can only type variable={variable} setVariable={setVariable} /> or state={state} dispatch={dispatch} /> so many times before deciding that maybe AI should just do all this for us.

Let’s look at a few patterns for handling state that needs to be accessed in many components across our application.

React’s built-in answer: useContext

We’ll start with a simple example: a global test-mode toggle, similar to Stripe.

This toggle affects everything - which API endpoints to hit, whether or not to display warnings in the dashboard, etc. To set it up, there’s nothing stopping us from doing this:

const App = () => {
    const [testMode, setTestMode] = useState(getDefaultTestMode())
    
    return <div>
        <TestModeToggle testMode={testMode} setTestMode={setTestMode} />
        <SomeComponent testMode={testMode} />
        <AnotherComponent testMode={testMode} />
    </div>
}

But you can see how this gets unwieldy very quickly. Every component that needs to know if test mode is enabled, needs the testMode prop passed to it. This process of passing down a prop through many layers of components is called prop drilling, and it gets annoying quickly.

In an ideal world, our components could just use hooks:

const SomeComponent = () => {
    const {testMode} = useTestMode()
    // ... etc
}

const TestModeToggle = () => {
    const {testMode, setTestMode} = useTestMode()
    // ... etc
}

and when we call setTestMode, it automatically updates all the components that call useTestMode. Luckily, this is totally possible with React Contexts.

Creating a React Context, Provider, and Custom Hooks

React Contexts enable you to make some state available to the entire tree of React components beneath it. If you place a React context at the very top of your application, every component in the application will have access to the state within it.

For our test mode example, we need to call createContext to create it, as well as provide a type for the state it’s managing:

// TestModeContext.tsx 

type TestModeState = {
    testMode: boolean
    setTestMode: (testMode: boolean) => void
}

const TestModeContext = React.createContext<TestModeState>({
    testMode: false,
    setTestMode: () => {},
})

There are then two things to do with the TestModeContext:

  • TestModeContext.Provider provides the values for testMode and setTestMode (in other words, it will store the TestModeState).
  • React.useContext(TestModeContext) will return the value stored by the closest provider.

It’s best practice to write our own wrapper around TestModeContext.Provider:

// TestModeContext.tsx 

export const TestModeProvider = ({ children }: { children: React.ReactNode }) => {
    // This context is actually powered by useState! 
    const [testMode, setTestMode] = useState(getDefaultTestMode())

    return (
        <TestModeContext.Provider value={{ testMode, setTestMode }}>
            {children}
        </TestModeContext.Provider>
    )
}

It’s also best practice to provide some easy-to-use hooks:

// TestModeContext.tsx 

export const useTestMode = () => {
    const context = React.useContext(TestModeContext)
    // context will be undefined if we are not within the TestModeProvider
    if (!context) {
        throw new Error('useTestMode must be used within a TestModeProvider')
    }
    return { testMode: context.testMode, setTestMode: context.setTestMode }
}

And now that we have everything ready, we just need to place the TestModeProvider at the top of our application (it can technically go anywhere, but only components underneath it get access):

// Example default Next.js _app.jsx file, that we've added the TestModeProvider to
function MyApp({ Component, pageProps }) {
    return (
        <TestModeProvider>
            <Component {...pageProps} />
        </TestModeProvider>
    )
}

and then any component will have access to our useTestMode hook. All calls to setTestMode will affect every component using that hook!

Contexts work with other state management primitives

If we zoom out for a second, all we’ve really done is made a global version of useState. In fact, our TestModeContext is literally using useState to manage the testMode boolean.

This is an important point - while the Context allows us to make our state global, we can still use all the same primitives we used before.

If we go back to our filter example, we needed to pass dispatch and state around everywhere. But if we create a FilterContext to store those values, and some custom hooks to expose them, you can avoid prop drilling and make a better developer experience:

const ZipCodeFilter = () => {
    // Option A:
    //   We use a hook to expose the reducer's output globally
    const {state, dispatch} = useFilters()

    // Option B:
    //   We provide more specific hooks.
    //   Under the hood, these are still calling dispatch, and our
    //   state is still centrally managed in the reducer
    const {zipCode, setZipCode, clearZipCode} = useZipCodeFilters()
    
    // ...
}

A library that makes it simpler: zustand

There is still a good amount of boilerplate that goes into making a Context - we needed to make and hook up providers. zustand is a state management library that makes the process of managing global state easier. The equivalent testMode example would look like this:

const useTestMode = create((set) => ({
    testMode: false,
    toggleTestMode: () => set((state) => ({ testMode: !state.testMode })),
}))

And then we can just use useTestMode across multiple components.

Section 3: Persisting the state

In all the examples in the first two sections, our state was stored in memory. For our initial Menu example, if you open the menu and refresh the page, the menu will reset to the default - closed.

That’s pretty reasonable for our menu, but what about a multi-step job application form? Accidentally refreshing and losing all your progress there is a lot more soul crushing.

Let’s see how we can avoid some hate mail.

Maintaining state across refreshes

Ultimately, this boils down to needing to store our state somewhere else. One common option is localStorage, which has a really simple API:

localStorage.setItem("name", "Andrew")
localStorage.getItem("name") // returns Andrew
// refresh the page
localStorage.getItem("name") // returns Andrew
localStorage.removeItem("name")
localStorage.getItem("name") // returns null

Let’s look at a hypothetical hook called useStateBackedByLocalStorage:

const TestModeToggle = () => {
    const [testMode, setTestMode] = useStateBackedByLocalStorage("testMode", false)
    
    return <Toggle enabled={testMode} onChange={setTestMode}>
        Test Mode
    </Toggle>
}

testMode and setTestMode will look & operate exactly like they would with useState.

The idea is that the hook will load the initial value from localStorage. If it doesn’t exist, it’ll fall back to the default of false. Calls to setTestMode will not only update testMode, it will also save the value in localStorage for the future.

The only difference when compared to useState is that we need to tell it what key to persist the state under, and now our state persists across refreshes!

Here’s an example implementation for this hook (named useLocalStorage). If you want to try and implement it yourself, look at the docs for useSyncExternalStore which will take care of a lot of the heavy lifting.

localStorage isn’t the only option. You can also persist data to IndexedDB, sessionStorage, or a non HTTP-only cookie which all have different tradeoffs. For a deeper dive into some of those tradeoffs, this article goes into more detail.

Making state shareable by saving in the URL as query parameters

Let’s move on to a different example that will highlight a different pain point: a paginated table. We’ll assume that we have two variables to track: the page number the user is on and an optional search query.

Our first attempt is to store them as state:

const [page, setPage] = useState(0)
const [query, setQuery] = useState('')

But we know what’s wrong here - a user that refreshes the page loses their query and page number leading to a pretty rough experience.

Easy enough, we switch to our fancy new hook:

const [page, setPage] = useStateBackedByLocalStorage('page', 0)
const [query, setQuery] = useStateBackedByLocalStorage('query', '')

This is better, but it has some shortcomings:

  • If a user opens the table in a new tab, they’ll be greeted with the same page number and query. In fact, our table state will be synced across all our tabs, which might not be what you want.
  • A user may want to share something interesting they found in the table with a coworker, but they have no way of doing that.

Instead of storing the state in memory, we can instead place it in the URL, like so:

https://example.com/table?page=0&query=something

This has some pretty cool properties:

  • It still persists across refreshes
  • Users can share links to their view of the table

Each framework will have a slightly different way of reading/writing query parameters, so you’ll need to check with yours for this approach.

One other thing to point out here: you may not want every state change to be reflected in the URL. If you don’t want to deal with reading/writing to the URL on each user interaction, you can instead change to a “Copy link to share” approach:

With this approach, the workflow looks like this:

  • Filter/page numbers are managed in memory
  • When the user clicks “Copy sharable link…”, it creates a URL that contains the filters, like https://example.com/table?page=3&query=somethingelse
  • When the component initially loads, it’ll populate it’s initial state by checking the URL

This can often make the implementation simpler since you only need to sync w/ the query parameters once.

Save your state to the database

While we’re primarily talking about client state / FE state, this list didn’t seem complete without this entry. You can always store state in your backend / database. This often looks as simple as:

await fetch('/preferences', {
    method: 'PUT',
    headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${accessToken}`,
    },
    body: JSON.stringify({ 
        emailMe: "absolutely_never" 
    })
})

And then it’s the backend’s responsibility to store the information. We’ll examine this more in a future post where we dig into managing server/asynchronous state.

Summary

In React applications, managing state is both critical and ubiquitous. Here’s an overview of the methods we discussed above:

  1. useState: This React hook manages simple state for individual components. It's ideal for managing straightforward state changes within a component. Example: managing the state of a menu's open/close state.
  2. useReducer: For more complex state logic, useReducer is preferred. It's useful when state changes involve more intricate updates or when multiple components need to interact with the same state object.
  3. Form State Management: If you are managing a form, dedicated libraries such as React Hook Form or Mantine's useForm provide efficient ways to manage form state, including handling validations and submission states.
  4. React Context: Useful for managing global state that needs to be accessed across multiple components without prop drilling. Contexts provide a way to share state between components without explicitly passing props through every level of the tree.
  5. Persisting State: To maintain state across page refreshes, you can use options like localStorage, sessionStorage, or query parameters. Prefer query parameters if the state should be shareable.

Overall, understanding these state management techniques and choosing the appropriate one based on your application's needs helps in building scalable and maintainable React applications.