Objectives
This guide covers the foundational concepts of state and state management, along with an exploration of three primary tools: Redux, Recoil, and the Context API.
Defining State and State Management
In JavaScript applications, state refers to the data that arises from user interactions. This data, stored in a plain JavaScript object, informs how React renders components whose content shifts based on user-triggered events. React components—whether classes or functions—can hold state. Passing data between components often relies on "prop drilling," where values travel repeatedly from parent to child nodes. However, as an application scales, a global store becomes essential for housing and sharing state across the project.
Take an e-commerce platform as an example. When a user interacts with one area of the app, that action triggers updates elsewhere. Consider these scenarios:
- Placing an item in a cart requires transferring data from the product component to the cart component.
- Recording a completed purchase must update the user's order history.
- Processing the final checkout steps for items in the cart.
- In sophisticated applications, tracing these modifications becomes unwieldy, driving the need for a structured approach to data flow and component communication. State management provides that deterministic structure.
- State management libraries supply utilities for creating these structures and managing data updates. Over time, a variety of such libraries have emerged, making the selection of a state management solution a critical development decision. This guide examines several libraries and practical usage patterns.
Hooks
React integrates multiple strategies for state handling. Choices range from class-based management to React Hooks, alongside third-party tools like Redux or Recoil. The React documentation recommends Hooks as a preferred approach, and this section demonstrates their use.
Per the official React Docs, Hooks are functions that grant access to state and other React capabilities directly within function components, bypassing the need for class syntax. Rather than maintaining a single, monolithic state object, Hooks allow segmenting state into discrete units that update independently.
Here, we focus on key Hooks for state management, specifically useState and useReducer.
Understanding useState
The useState Hook enables defining and modifying state, echoing the behavior of this.state in class components. Its key benefit is setting values without needing to reference the existing state.
**const** [state, setState] = useState(initialState);
useState accepts the starting state (designated as initialState). It returns an array containing exactly two entries, which are unpacked via destructuring into freely named variables.
The first element (state) holds the current value available for use in your component.
The second element (setState) is the function responsible for resetting that value.
Let’s illustrate this Hook with a simple Movies component example.
import React, { useState } from "react";
// array of movie objects
const movies = [
{
title: "Black Widow",
price: "12.00",
rating: "4.0"
},
{
title: "Justice Leage - Snyder Cut",
price: "10.00",
rating: "4.9"
}
];
export default function Movies() {
// create state variables with initial values
const [bookedMovies, setBookedMovies] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
// functions to set the state
const add = () => {
// set state for bookedMovies
setBookedMovies([movies[0]]);
// set state for totalPrice
setTotalPrice(movies[0].price);
};
// reset state values
const remove = () => {
// reset values
setBookedMovies([]);
setTotalPrice(0);
};
return (
<div>
<header>
<p> No. of movies: {bookedMovies.length} </p>
<p> Total price: ${totalPrice} </p>
</header>
<ul className="movies">
{movies.map((movie) => {
return (
<li className="movie" key={movie.name}>
<header>
<h3> {movie.title} </h3>
<p> ${movie.price} </p>
</header>
<button onClick={add}> Add </button>
<button onClick={remove}> Remove </button>
</li>
);
})}
</ul>
</div>
);
}
// BeforeUnloadEvent(()=>{
// alert('before now')
// })
The snippet above begins by bringing in the useState Hook. Two standalone state variables are then declared: bookedMovies and totalPrice.
This representation highlights a clear benefit of useState over traditional class state, which is confined to one aggregate object. Hooks facilitate several independent states; you simply invoke useState once per piece of state, passing a default value and capturing its pair of properties.
Now, we'll wire up a function to set these values. The goal is letting users update state with preset data. To that end, we'll implement a function that injects a new movie into the bookedMovies list and also updates the price.
...
export default function Movies() {
// create state variables with initial values
const [bookedMovies, setBookedMovies] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
// functions to set the state
const add = () => {
// set state for bookedMovies
setBookedMovies([movies[0]]);
// set state for totalPrice
setTotalPrice(movies[0].price);
};
// reset state values
const remove = () => {
// reset values
setBookedMovies([]);
setTotalPrice(0);
};
return (
<div>
<header>
<p> No. of movies: {bookedMovies.length} </p>
<p> Total price: ${totalPrice} </p>
</header>
<ul className="movies">
{movies.map((movie) => {
return (
<li className="movie" key={movie.name}>
<header>
<h3> {movie.title} </h3>
<p> ${movie.price} </p>
</header>
<button onClick={add}> Add </button>
<button onClick={remove}> Remove </button>
</li>
);
})}
</ul>
</div>
);
}
The code defines two functions, add() and remove(). The former leverages setBookedMovies to amend bookedMovies and setTotalPrice to adjust totalPrice.
This add() function is triggered via the onClick event attached to the Add button.
Upon clicking, bookedMovies takes on the first movie title ("Black Widow") from the list, while totalPrice records that movie's cost (12.00).
In contrast, invoking remove() empties bookedMovies back to [ ] and resets totalPrice to 0.
That demonstrates the mechanics of setting and altering state through useState. However, the above relies on preconfigured, static data. In practical apps, you'll often need the latest state value to compute future updates. Enter the useReducer Hook to handle transformations based on the current snapshot.
Grasping the useReducer Hook
Up to this point, changes have replaced the old state entirely with fixed constants. Real-world requirements, though, usually involve appending to existing data. useReducer is purpose-built for that, letting updates derive from the previous state. Indeed, it echoes the logic of the Array reduce method.
Our first step is a bookedMoviesReducer function. It accepts two parameters: state and action. Here, state is the existing snapshot, while action is an object holding the movie payload and an operation label—"add" or "remove".
...
function bookedMoviesReducer(state, action) {
// get the movie object and the type of action by destructuring
const { movie, type } = action
// if "add"
// return an array of the previous state and the movie object
if (type === "add") return [...state, movie]
// if "remove"
// remove the movie object in the previous state
// that matches the title of the current movie object
if (type === "remove") {
const movieIndex = state.findIndex( x => x.title === movie.title)
// if no match, return the previous state
if(movieIndex < 0 ) return state
// avoid mutating the original state, create a copy
const stateUpdate = [...state]
// then splice it out from the array
stateUpdate.splice(movieIndex, 1)
return stateUpdate
}
return state
}
...
export default function Movie(){
...
}
Likewise, the totalPriceReducer function mirrors the signature of bookedMoviesReducer, yet it merely increments or decrements the total by the price from the incoming data.
...
function totalPriceReducer(state, action) {
const{ price, type } = action
if (type === "add") return state + price
// return the value when the type of action was not "add"
// subract the new movie price from the previous state
return (state - price) < 0 ? 0 : ( state - price )
}
...
export default function Movie(){
...
}
Pay attention to the ternary operator inside the return statement. This guard resets the total to 0 whenever the computed difference would dip below zero.
Equipped with these refactored reducers, we now integrate them into our component via useReducer.
import React, { useReducer } from 'react';
...
export default function Movies() {
// replace useState with useReducer and pass two arguments
// the first argument is the reducer function
// the second function is the initialState
const [bookedMovies, setBookedMovies] = useReducer(bookedMoviesReducer, []);
const [totalPrice, setTotalPrice] = useReducer(totalPriceReducer, 0);
// function to add items and price
const add = (movie) => {
// pass an object containing the movie and type of action, "add"
setBookedMovies({ movie, type: "add" });
setTotalPrice({ price: movie.price, type: "add" });
};
// function to remove items and price
const remove = (movie) => {
setBookedMovies({ movie, type: "remove" });
setTotalPrice({ price: movie.price, type: "remove" });
};
return (
...
)
}
...
Having modified add() and remove() to accept a movie parameter, we can inject the desired movie directly into the onClick handler within the template.
...
{movies.map((movie) => {
return (
<li className="movie" key={movie.name}>
<header>
<h3> {movie.title} </h3>
<p> ${movie.price} </p>
</header>
<button onClick={() => add(movie)}> Add </button>
<button onClick={() => remove(movie)}> Remove </button>
</li>
);
})}
...
Now, clicking the Add button triggers add(), which accepts a movie object. That payload travels onward to setBookedMovies and setTotalPrice, the dispatchers that useReducer exposed.
const [ bookedMovies, setBookedMovies ] = useReducer(bookedMoviesReducer, []);
const [ totalPrice, setTotalPrice ] = useReducer(totalPriceReducer, 0);
Each dispatcher expects an object argument.
For setBookedMovies, the expected structure is {movie, type: "add"}. Alternatively, setTotalPrice looks for {price: movie.price, type: "add"}.
These dispatched objects proceed into their corresponding reducers. For instance, setBookedMovies forwards its object straight into bookedMoviesReducer.
That reducer merges the fresh data with the prior state, preserving what was there before.
The linked CodeSandbox lets you observe this directly.
The next topic covers coordinating state among multiple components using a combination of Hooks, Props, and the Context API.
Understanding the Context API
The React Context API started as an experimental feature and reached production readiness in React version 16.3.0.
As the official documentation describes, Context enables data to flow through the component tree without needing to thread props manually at every level.
That manual prop-passing process is what developers commonly call prop drilling.
By leveraging Context, you establish a "global" state for a specific portion of the component tree, making that data reachable from any nested component without intermediate hops.
To see Context in action, let's split our example application into two separate components:
BookedMovies– Handles the count of reserved movies along with the cumulative costMovies– Displays the full movie list, each item's information, and the add and remove controls to modify the reservation list.
At the moment, all our state variables — the movie list, bookedMovies, and totalPrice — are managed via Hooks inside a single App.js component.
To make that state available to child components, we'd normally pass it down as props.
This means we'd have to lift the state up to the root of the tree and then forward it to whichever component actually needs it, possibly several layers deep — which brings us right back to prop drilling.
Context offers a cleaner alternative: initialize Context, define a "global" state, and let any component on the tree tap into it directly.
Initializing Context
To set up context, we start by creating a dedicated MoviesContext.js file, importing createContext, and instantiating our Context object.
import React, {useReducer, createContext} from 'react';export const MovieContext = createContext();
Building the Context Provider
With Context initialized, the next step is to set up a Provider. Per the official docs, every Context object includes a Provider component, and any component nested inside it gets access to context changes.
import React, { useState, useReducer, createContext } from "react";
const moviesList = [
...
];
function bookedMoviesReducer(state, action) {
...
};
export const MovieContext = createContext();
export const MovieProvider = (props) => {
const [movies, setMovies] = useState(moviesList);
const [bookedMovies, setBookedMovies] = useReducer(bookedMoviesReducer, []);
return <MovieContext.Provider>{props.children}</MovieContext.Provider>;
};
In this sample, we've relocated all state and reducer logic into the Context file MoviesContext.js. Inside the MovieProvider function, we initialize state using the useState and useReducer Hooks.
At the end, we're returning the Provider component <MovieContext.Provider>. The props.children pattern lets us render whichever components we place inside the provider.
Here's the refactored App.js file, containing the imported components and Context.
// ./App.js
import React from "react";
import Movies from "./components/Movies";
import BookedMovies from "./components/BookedMovies";
import { MovieProvider } from "./moviesContext";
export default function App() {
return (
<MovieProvider>
<main>
<Movies />
<BookedMovies />
</main>
</MovieProvider>
);
}
We've now created a provider and wrapped our app's components with it. However, the state from Context isn't yet available to the components. Let's see how to actually consume it.
Accessing the state
The Provider component takes a value prop, which descendant components can access. We need to pass all of our state down through the provider's value prop in MoviesContext.js
// ./MoviesContext.js
...
return (
<MovieContext.Provider
value={{
movies,
setMovies,
bookedMovies,
setBookedMovies,
}}
>
{props.children}
</MovieContext.Provider>
);
...
To subscribe to these pieces of state within our components, we import MovieContext and use the useContext Hook. Here's how that looks inside the Movies component.
// ./components/Movies.js
// import the useContext Hook
import React, { useContext } from "react";
// import the Context
import { MovieContext } from "../moviesContext";
export default function Movies() {
const { movies, setBookedMovies } = useContext(MovieContext);
// function to add items and price
const add = (movie) => {
// pass an object containing the movie and type of action, "add"
setBookedMovies({ movie, type: "add" });
};
// function to remove items and price
const remove = (movie) => {
setBookedMovies({ movie, type: "remove" });
};
return (
<ul className="movies">
{movies.map((movie) => {
return (
<li className="movie" key={movie.name}>
<header>
<h3> {movie.title} </h3>
<p> ${movie.price} </p>
</header>
<button onClick={() => add(movie)}> Add </button>
<button onClick={() => remove(movie)}> Remove </button>
</li>
);
})}
</ul>
);
}
From this point, all our state is accessible. The same pattern applies to the BookedMovies component.
// ./components/BookedMovies.js
import React, { useContext } from "react";
import { MovieContext } from "../moviesContext";
function BookedMovies() {
const {bookedMovies} = useContext(MovieContext);
const getTotalPrice = (bookedMovies) => {
const totalPrice = bookedMovies.reduce((totalCost, item) => totalCost + item.price, 0);
return totalPrice
}
return (
<header>
<p> No. of movies: {bookedMovies.length} </p>
<p> Total price: ${getTotalPrice(bookedMovies)} </p>
</header>
);
}
export default BookedMovies;
That's it. We've successfully subscribed to the bookedMovies state from our Context. Additionally, we've reworked the price calculation into a new helper, getTotalPrice, which sums the price of each film in the bookedMovie array.
This is how state management works with the Context API combined with Hooks.
Up next, we'll look at alternative solutions like Redux and Recoil.
Introducing Redux
What is Redux?
Redux serves as a state container for JavaScript applications. It helps us oversee application state, tracking and controlling changes as the app runs. Redux allows React components to pull data from a Redux store and send actions to that store to modify the data. Here's a visual overview of how Redux operates:
The UI gets refreshed based on how the state within the store changes, which makes ongoing updates straightforward.
Redux establishes a single, central store where all state lives. Any component in the application can then access the stored data without passing it manually between components.
Choosing Redux for Your Project
Redux makes sense for your app when:
- You're dealing with a large volume of state that needs frequent updates. It addresses prop drilling and simplifies handling continuous state changes.
- Updating the app's state requires a more sophisticated algorithm.
- There's a noticeable amount of data changing over time. For applications where data is consistently in flux, Redux helps organize and manage it.
- State management is spread across a team. Redux is well-suited for large codebases where multiple developers are collaborating.
The Mechanics of Redux
Redux consists of three core building blocks: the Redux Store, Actions, and Reducers.
Redux Store: This acts as the core of our state architecture. It holds the state and is responsible for dispatching Actions to manage the application's state.
Action: These are payloads of information that Reducers can interpret. They capture details about what the user did. Each action features a type and a payload. The type is a string identifying the action; the payload holds the actual data.
Reducers: These are pure functions that read the instructions from Actions and update the store's state accordingly. They dictate how the application state responds to the actions dispatched to the store.
Beyond UI management, Redux brings architectural clarity to React apps and improves performance, making component re-rendering more efficient when necessary.
Redux's key advantages are:
- Flexibility: Redux is easy to pick up and start using. You don't need deep expertise to benefit from it.
- Maintainable: Redux offers a clear, consistent way to manage and control the stored state.
- Scalability: Thanks to a central store shared by components, Redux handles complex application states well.
- Server-side rendering support: You can send the app's current state along with the server response to update state.
- Debugging ease: With its three-part structure — Store, Actions, and Reducers — you can log each piece to locate and fix issues efficiently.
Time to see Redux in action. We'll walk through a basic counter app setup.
Configuring Redux
First, we need to install Redux and its React bindings via the CLI:
npm i redux react-redux
With the packages installed, we'll set up our action and reducer files: action.js and reduce.js.
Inside reduce.js, we write:
const reduce = (state = 0, action) => {
switch (action.type) {
case "reset value":
return (state = 0);
case "increase value":
return state + 1;
case "decrese value":
return state - 1;
default:
return state;
}
};
export default reduce;
In the code above, our reducer starts with an initial state of "0" and modifies it based on the action.type string. A conditional block directs how the state changes for each action. Specifically, a "reset value" action sets state to 0, an "increase value" action increments by one, and a "decrease value" action decrements accordingly.
Establishing a Central Store
To set up the store, we create a new file and import createStore from Redux:
import { createStore } from "redux";
We'll also bring our reducer into this file. Then we import Provider from react-redux. The Provider bridges the central state to our React app, and we wrap the components that need Redux state — typically at the root level.
import reduce from "./reduce"
import { Provider } from "react-redux";
Next, we create the store:
const store = createStore(
reduce,
);
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
We need to define and export our actions within the action.js file:
export const increment = () => {
return {
type: "increment value",
};
};
export const decrement = () => {
return {
type: "decrement value",
};
};
export const reset = () => {
return {
type: "reset value",
};
};
Dispatching Actions
To trigger increment value, reset value, and decrement value, we import our action creators and use the useDispatch hook from react-redux.
import { useDispatch } from "react-redux";
In App.js, we wire up buttons to invoke these actions:
import { useSelector, useDispatch } from "react-redux";
import {
decrement,
increment,
reset,
} from "./action";
function App() {
const counter = useSelector((state) => state.counter);
const dispatch = useDispatch();
return (
<div className="App">
<p>Counter to demonstrate redux</p>
<h3>{counter}</h3>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(reset())}>Reset</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
</div>
);
}
export default App;
Here, the useDispatch hook assigns the actions to three distinct buttons. The counter's display value pulls directly from the current state.
Exploring Recoil
What is Recoil?
Recoil is a state management library open-sourced by Facebook. It tackles global state within applications, built upon two primary concepts: Atoms and Selectors. An Atom is a function that stores a state value, enabling shared-state architecture where various components connect to retrieve that value. A Selector, in contrast, works similarly but holds derived state — meaning its value can be computed from an Atom or from another Selector.
Configuring Recoil
We'll build a counter app similar to the Redux example to illustrate Recoil. To install it, use this command in your terminal:
npm install recoil
Now we can import Recoil into our app and define our state:
//atom.js
import { atom } from "recoil";
const counter = atom({
key: "counter",
default: 0
});
export default counterAtom;
Here, counter represents our state, identified by a "counter" key with a default value of 0. Components using Recoil state need to be wrapped in a RecoilRoot, which we'll add to the root component:
//index.js
import React from "react";
import ReactDOM from "react-dom";
import { RecoilRoot } from "recoil";
import App from "./App";
ReactDOM.render(
<RecoilRoot>
<App />
</RecoilRoot>,
document.getElementById("root")
);
With Recoil, we use the useRecoilState() hook to access the value stored in a Recoil state. Now, we create buttons that modify the state defined in atom.js:
// App.js
import React from "react";
import { useRecoilState } from "recoil";
import counter from "./atom";
const App = () => {
const [count, setCount] = useRecoilState(counter);
return (
<div>
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<span>{count}</span>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
</div>
);
};
export default App;
In this example, we read the current value from atom.js using useRecoilState, then set up three buttons to increment, decrement, and reset the count.
Why Choose Recoil
- Recoil offers a straightforward approach to state management, fitting well with simpler app structures. With only Atoms and Selectors to learn, the learning curve is minimal.
- Its API feels familiar to React developers since it resembles the
useStatehook, making integration into React projects more intuitive.
Wrapping Up
Throughout this series, we've explored a variety of techniques and libraries for handling state in React applications. We've moved from the built-in Hooks and Context API, which suit smaller projects well, to more robust solutions like Redux and Recoil for larger codebases. The React ecosystem, however, offers a much wider array of state management tools, each crafted around a distinct philosophy and aimed at solving specific problems.
Here is a brief look at some other notable state management libraries:
- XState – This library is built upon the concept of state machines. With state machines, you define specific actions or behaviors that execute for each defined state in your application. A simple analogy for this pattern is a switch-case statement in JavaScript.
switch (state) {
case state === 'light':
// enable light theme
break;
case state === 'dark':
// enable light theme
break;
default:
// handle error
}
- To dive deeper into XState and state machines, you can refer to this in-depth article, the official documentation, or this comprehensive video course.
- MobX – An unopinionated and framework-agnostic state management solution that works outside of React as well. It applies a transparent functional reactive programming (TFRP) approach. In contrast to Redux's single-store model, MobX supports multiple, independent stores. Its gentle learning curve makes it easy to get started with MobX.
- Valtio – A lightweight library that leverages proxies to offer a straightforward, mutation-based API for state updates.
- Jotai – A minimalistic and flexible state management solution for React. It provides strong TypeScript support and is designed with a focus on deriving computed values and handling asynchronous state updates.
- Zustand – A compact, performant, and scalable library that targets module-level state. It is Hook-based and, like MobX, is unopinionated about your application's structure.
Looking Ahead: The Evolution of React State
The state management approaches we've covered are not without their challenges, especially when applications scale. One common pain point is the excessive re-rendering we encounter when combining Hooks with the Context API. When the value of a Context changes, all components using the useContext hook to consume that Context will re-render, even if the specific piece of data they rely on hasn't been altered.
React provides a built-in workaround for this with the useMemo hook, which allows you to memoize expensive computations and prevent unnecessary re-runs.
To address this issue more fundamentally, the React team is developing a new feature tentatively called useSelectedContext. This hook would enable components to subscribe only to a specific slice of the Context value, rather than the entire object. You can track the development progress of this feature on its dedicated GitHub PR.
While useSelectedContext is poised to be a native solution, third-party alternatives already exist. The useContextSelector library operates on a similar principle and is, in fact, a key internal dependency for Jotai and Formik 3.
In the long term, React is exploring auto-memorization, a mechanism to automatically and intelligently determine which components need to re-render when state changes, eliminating the need for manual optimization.
Given this roadmap, along with the wealth of available state management options designed for various purposes, React remains a strong and versatile foundation for teams of all sizes when building their products.
