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:
This predictability makes it easier to debug and also avoid duplication of efforts.
A state value is typically classified based on how it is shared and accessed by the components in the application.
Based on the scope of data and nature of access state management scenarios can be broadly bucketed into following categories:
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.
Readonly data fetched from a remote API scoped to a small set of components without any prop drilling.
Data fetched from a remote API and involves both read and write operations.
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:
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:
import React, { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(index + 1)}>{count}</button>
</>
);
}
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:
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 };
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>
);
}
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>
);
};
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:
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>
</>
);
}
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.
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.
React State Management Libraries 2023