How to manage state in React apps
September 3rd, 2023

State represents any piece of data that can change over time and affects the behavior and appearance of the user interface. State management refers to the process of managing and handling the state of your application. This could be managing visibility of a dialog or fetching, storing and manipulating data from a remote API.

In this post we will look at different state management solutions for react apps and arrive at the most suitable one for a given scenario. The goal is to make sure there is a:

  1. Consistency in approach and technique across the app
  2. A standardized solution to make it easier while adopting new strategies like server-side-rendering.
  3. Robust solution, with ways to handle error and corner cases.

This predictability makes it easier to debug and also avoid duplication of efforts.

Classification of state

A state value is typically classified based on how it is shared and accessed by the components in the application.

  • Local state – Data we manage within a small subset of components.
  • Global state – Data we access and manage across the entire app
  • URL state – Data that is stored in the browser’s URL, such as the history state and query parameters.

Types of state management scenarios

Based on the scope of data and nature of access state management scenarios can be broadly bucketed into following categories:

1
UI lifecycle management

Commonly involves a state variable that toggles the visibility of UI elements, such as modals, drop-downs, or accordions.

Form inputs or counter increments/decrements values, UI behavior overrides(e.g click handling), shared color schemes can also be grouped under this category.

2
Readonly API data management (Local)

Readonly data fetched from a remote API scoped to a small set of components without any prop drilling.

3
Read/Write API data management (Local or Global)

Data fetched from a remote API and involves both read and write operations.

Choosing the right solution

Rather than one form fits all, it's important to choose the right solution based on the complexity of the state management scenario.
Here are a few guidelines to help you choose the right solution:

UI lifecycle management

React's state hooks like useState is ideal for local UI lifecycle state management. You can choose to create one or more state variable based on the requirement.

Example:

Counter.jsx
import React, { useState } from "react";
 
export default function Counter() {
    const [count, setCount] = useState(0);
 
    return (
        <>
            <button onClick={() => setCount(index + 1)}>{count}</button>
        </>
    );
}
UI interaction/behavior and shared styles

To conditionally override/share UI interaction, or share color schemes across components but scoped to a subsection of the app, you could use React Context without having to pass props through every level of the tree.

Context lets a parent component provide data to the entire tree below it.
How to use React Context effectively is a good read to understand best practices around context.

Example:

1
Create a context provider
HeaderContext.tsx
import React, { useContext } from "react";
import { ColorScheme } from "mini-color/lib/schema/ColorScheme";
 
type HeaderState = { primary: boolean; colorScheme: ColorScheme };
 
const HeaderStateContext = React.createContext(undefined);
 
interface HeaderContextProviderProps {
    children: React.ReactNode;
    state: HeaderState;
}
 
// create a custom consumer hook
// this has the benefit of you being able to do additional validation and common operations e.g. telemetry.
const useHeaderState = () => {
    const headerState = useContext(HeaderStateContext);
 
    if (typeof headerState === "undefined") {
        throw new Error(
            "useHeaderState must be used within a HeaderContextProvider"
        );
    }
 
    return headerState;
};
 
// provide the context
const HeaderContextProvider = (props: HeaderContextProviderProps) => {
    const { children, state } = props;
 
    // wrap the children with a context provider to provide the HeaderStateContext:
    return (
        <HeaderStateContext.Provider value={state}>
            {children}
        </HeaderStateContext.Provider>
    );
};
 
export { HeaderContextProvider, useHeaderState };
2
Provide the context
Header.tsx
import React from 'react';
import { HeaderContextProvider } from '../context/HeaderContext';
 
export default function Header(props: ...) {
    // ...
 
    const { primary, colorScheme } = props;
 
    // use a memoized state rather than creating an object on every re-render
    // this would help optimize re-renders
    const state = useMemo(() => ({
        primary, colorScheme
    }), [primary, colorScheme])
 
    return (
        <HeaderContextProvider state={state}>
            <header>
                <HeaderTitle />
            </header>
        </HeaderContextProvider>
    );
}
3
Use the context
HeaderTitle.tsx
export const HeaderIcons = (props: HeaderIconsProps) => {
    const { content, rightAligned = false, className } = props;
    const { titleAlign } = useHeaderState();
 
    return (
        <div
            className={cx(
                styles.iconsContainer,
                titleAlign === "center" && styles.titleCenter,
                rightAligned && styles.rightAligned,
                className
            )}
        >
            {content}
        </div>
    );
};
Readonly API data management (Local)

A custom hook that builds on top of react's state and lifecycle hooks is best suited for this scenario.
This provides the right amount of abstraction to fetch and manage data from a remote API with minimal boilerplate and contextual overhead like stores and change observers.

You could choose to write your own or use an open source solution like useAsync

Example:

FindPeople.ts
import { useAsync } from 'react-async-hook';
 
const fetchPeople = async (id: string) => { //... }
 
export default function SearchBox(props: SearchBoxProps) {
    const [query, setQuery] = useState(EMPTY_STRING);
    const asyncResult = useAsync(fetchPeople, [id]);
 
    return (
        <>
            <input type="text" value={query} />
 
            <div>
                {asyncResult.result?.map((entity, index) => (
                    <Entity key={key} entity={entity} />
                ))}
            </div>
        </>
    );
}
Read write API data management (Global)

As the application grows in complexity with concurrent data access and modification from multiple components, it becomes important to to structure your state well to keep the state management logic maintainable.

We'll cover a few popular solutions for managing global state. Each one of these solutions provides a powerful and flexible way to model and manage application behavior.

Zustand utilizes React hook API for creating and accessing global state. It makes state updated predictable through immutability.

XState helps you model complex application behavior as finite state machines, enabling precise control over state transitions and providing a clear visual representation of how your app behaves in different states.

Jotai embraces the concept of "atoms," which are individual units of state that can be composed and combined to build complex application logic.
It's a minimalistic lib that neatly abstracts managing subscriptions and reactivity involved in state management.

Recoil provides a unique approach to global state management by allowing components to access and modify global state atoms at any level of the component tree. It does so by using Atoms are units of state and tracking the dependencies of each component on atoms

Mobx and MST popularized the reactive state management model, which simplifies state handling by automatically tracking and updating dependent components when data changes. They also offer an excellent devtools to visualize and debug state changes.

Pick the library that aligns with your mental model of state management(e.g state-machine, reactive programming, etc) and the complexity of the project.

It's equally important to standardize the usage patterns, like, naming conventions, store structure and ways to trigger side effects. It makes discovery and debugging manageable as the application and codebase grows in size and complexity.

In a future series, we'll deep dive into each of these solutions with practical examples.

References

State: A Component's Memory

React State Management Libraries 2023