All kinds of state management in React
Not all state is made equal in React. You may not need useState, Redux, TanStack Query or any other library. It depends to the kind of state you need to manage. These are your options.
Sandro Maglione
Contact me1222 words
γ»Not all state is the same in React. Before committing to any library it's key to understand the kind of state necessary for your app. Your framework may already provide all that you need, especially since React 19.
You may not need any state management library after all.
URL state
The easiest kind of state is URL state. Key-value store as part of the URL: /search?query=abc
.
URL state is ideal for simple values and to store updates as part of the browser history.
An example is toggling between a list
and view
layout using a radio
input.
export default function Page() {
return (
<div>
<input
name="view"
type="radio"
value="list"
/>
<input
name="view"
type="radio"
value="tiles"
/>
</div>
);
}
Keeping filters and selections as part of the browser history allows the user to remove the selection by clicking the back button.
onChange
pushes the same page with the added search parameters to the URL:
"use client";
import { usePathname, useRouter } from "next/navigation";
export default function Page() {
const pathname = usePathname();
const router = useRouter();
// π Don't need to store value in `useState`, store it in URL instead (`URLSearchParams`)
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const urlSearchParams = new URLSearchParams();
urlSearchParams.set("view", event.target.value);
router.push(`${pathname}?${urlSearchParams.toString()}`, { scroll: false });
};
return (
<div>
<input
name="view"
type="radio"
value="list"
onChange={onChange}
/>
<input
name="view"
type="radio"
value="tiles"
onChange={onChange}
/>
</div>
);
}
When you select an option the URL updates with the key view
(name
of the input) and value list
or tiles
(try below and check the URL π):
next
provides the useSearchParams
hook to read search parameters from the URL. We extract the view
key from it and set the defaultChecked
parameter of each input:
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export default function Page() {
const pathname = usePathname();
const searchParams = useSearchParams();
const view = searchParams.get("view") ?? undefined;
const router = useRouter();
// π Don't need to store value in `useState`, store it in URL instead (`URLSearchParams`)
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const urlSearchParams = new URLSearchParams(searchParams);
urlSearchParams.set("view", event.target.value);
router.push(`${pathname}?${urlSearchParams.toString()}`, { scroll: false });
};
return (
<div>
<input
name="view"
type="radio"
value="list"
// π Apply default value from `URLSearchParams`
defaultChecked={view === "list"}
onChange={onChange}
/>
<input
name="view"
type="radio"
value="tiles"
// π Apply default value from `URLSearchParams`
defaultChecked={view === "tiles"}
onChange={onChange}
/>
</div>
);
}
The value will be selected by default even when you reload the page (try it π).
Another option with next
to read search parameters is inside server components. Each page has access to props containing searchParams
as Promise
:
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function Page(props: Props) {
const searchParams = await props.searchParams;
const view = searchParams.view ?? undefined;
/// ...
}
Hooks like useRouter
or usePathname
are not available in server components. A way to keep both searchParams
props and hooks in the same component is the new use
function:
"use client"; // π Don't need to make a separate file for client-side code
import { usePathname, useRouter } from "next/navigation";
import { use } from "react";
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
// π Don't need to make `async` server component
export default function Page(props: Props) {
// Extract async `searchParams` with the new `use` function (React 19)
const searchParams = use(props.searchParams);
const view = searchParams.view ?? undefined;
const pathname = usePathname();
const router = useRouter();
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const urlSearchParams = new URLSearchParams();
urlSearchParams.set("view", event.target.value);
router.push(`${pathname}?${urlSearchParams.toString()}`, { scroll: false });
};
return (
<div>
<input
name="view"
type="radio"
value="list"
defaultChecked={view === "list"}
onChange={onChange}
/>
<input
name="view"
type="radio"
value="tiles"
defaultChecked={view === "tiles"}
onChange={onChange}
/>
</div>
);
}
In all the examples notice how the input is uncontrolled. Since the state is stored in the URL no useState
is needed. This is how to implement the same with useState
(controlled):
"use client";
import { useState } from "react";
export default function Page() {
// π With search parameters no need to store value in `useState`
const [view, setView] = useState<string | undefined>(undefined);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setView(event.target.value);
};
return (
<div>
<input
name="view"
type="radio"
value="list"
// π `checked` instead of `defaultChecked` for controlled state
checked={view === "list"}
onChange={onChange}
/>
<input
name="view"
type="radio"
value="tiles"
// π `checked` instead of `defaultChecked` for controlled state
checked={view === "tiles"}
onChange={onChange}
/>
</div>
);
}
URL state is so common that most React frameworks include special support for search parameters.
TanStack Start for example supports type-safe URL state.
Server state
Server state refers to data that is managed by the server, rather than being local to the client.
You typically read the data when the page loads. The data is not updated on the client, but instead reloaded after a server update (POST
, PUT
or DELETE
request).
Since this data is only read once when the page loads, you don't need to make the reading request on the client. Instead, you usually preload the data and HTML on the server.
That's the purpose of server components:
interface Post {
title: string;
body: string;
}
export default async function Page() {
// π Server request directly inside server component (React 19)
const post = await fetch("https://jsonplaceholder.typicode.com/posts/1").then(
(response) => response.json() as Promise<Post>
);
return (
<div>
<h2>{post.title}</h2>
<p>{post.body}</p>
</div>
);
}
The server component is marked as async
and the fetch
request is made directly inside the component function. The client receives the final layout without needing to make another request to the server.
After a server update (
POST
/PUT
/DELETE
) you need to manually reload the page to reflect the new data.
If instead you prefer revalidating the data on the client you use solutions like TanStack Query.
useQuery
makes the async request when the page loads (on the client) and stores the response in cache. A new request is periodically made to revalidate the data:
"use client"; // π Client component
import { useQuery } from "@tanstack/react-query";
interface Post {
title: string;
body: string;
}
export default function Page() {
const result = useQuery({
queryKey: ["post"],
queryFn: () =>
fetch("https://jsonplaceholder.typicode.com/posts/1").then(
(response) => response.json() as Promise<Post>
),
});
if (result.isPending) {
return <div>Loading...</div>;
} else if (result.isError) {
return <div>Error: {result.error.message}</div>;
}
return (
<div>
<h2>{result.data.title}</h2>
<p>{result.data.body}</p>
</div>
);
}
You need to check both
isPending
andisError
to make suredata
is defined (remove| undefined
from its type).
Since the request is made on the client, at first load the user will see the loading state (isPending
). This doesn't happen with server components since the page is loaded and ready when sent to the client.
Form state
Form state stores input data with the objective of submitting a form
request.
React 19 introduces the useActionState
hook specifically for form requests. useActionState
stores the request state (usually the response data) and takes care of the pending status. The action
is assigned to the form
:
export default function Page() {
const [state, action, isPending] = useActionState(api, null);
return (
<form action={action}>
<input type="text" name="email" autoComplete="email" />
<input type="password" name="password" autoComplete="current-password" />
<button type="submit" disabled={isPending}>
Submit
</button>
</form>
);
}
api
implements an async
request (or synchronous) by extracting the values from FormData
(based on the inputs' name
).
"use client";
import { useActionState } from "react";
type State = null; // π Change with your response type
const api = async (
previousState: State,
formData: FormData
): Promise<State> => {
const email = formData.get("email");
const password = formData.get("password");
console.log(email, password);
await new Promise((resolve) => setTimeout(resolve, 2000));
return null;
};
export default function Page() {
const [state, action, isPending] = useActionState(api, null);
return (
<form action={action}>
<input type="text" name="email" autoComplete="email" />
<input type="password" name="password" autoComplete="current-password" />
<button type="submit" disabled={isPending}>
Submit
</button>
</form>
);
}
The inputs are all uncontrolled, their values are extracted from
FormData
. No need of multipleuseState
π
With this implementation the request is sent inside the client. Therefore, the console.log
will print in the browser console.
The values inside the form inputs will be reset to empty when the request completes (default React/Web behavior).
Server actions
When you want to execute the action inside the server you can use server actions.
The implementation is the same, but the api
function is located in a separate file marked with "use server"
:
"use server";
type State = null;
export const api = async (
previousState: State,
formData: FormData
): Promise<State> => {
const email = formData.get("email");
const password = formData.get("password");
console.log(email, password);
await new Promise((resolve) => setTimeout(resolve, 2000));
return null;
};
The component references api
just like before inside useActionState
:
"use client";
import { useActionState } from "react";
import { api } from "./api";
export default function Page() {
const [state, action, isPending] = useActionState(api, null);
return (
<form action={action}>
<input type="text" name="email" autoComplete="email" />
<input type="password" name="password" autoComplete="current-password" />
<button type="submit" disabled={isPending}>
Submit
</button>
</form>
);
}
With this implementation api
will be executed on the server. Therefore, console.log
prints in the server logs (nothing is printed in the browser).
Mutations with TanStack Query
Another option is using mutations from TanStack Query with the useMutation
hook.
useMutation
takes the function to execute, just like useActionState
. It's not possible to directly use the action
of a form
. Instead, you can execute the mutation onSubmit
:
You must manually call
preventDefault
to avoid reloading the page.
export default function Page() {
const mutation = useMutation({
mutationFn: api,
});
return (
<form
onSubmit={(event) => {
event.preventDefault(); // π Requires manual `preventDefault`
mutation.mutate(new FormData(event.currentTarget));
}}
>
<input type="text" name="email" autoComplete="email" />
<input type="password" name="password" autoComplete="current-password" />
<button type="submit" disabled={mutation.isPending}>
Submit
</button>
</form>
);
}
api
is the same as before. It receives FormData
and executes the function on the client. Even in this case the state is uncontrolled and extracted as FormData
:
"use client";
import { useMutation } from "@tanstack/react-query";
const api = async (formData: FormData) => {
const email = formData.get("email");
const password = formData.get("password");
console.log(email, password);
await new Promise((resolve) => setTimeout(resolve, 2000));
};
export default function Page() {
const mutation = useMutation({
mutationFn: api,
});
return (
<form
onSubmit={(event) => {
event.preventDefault(); // π Requires manual `preventDefault`
mutation.mutate(new FormData(event.currentTarget));
}}
>
<input type="text" name="email" autoComplete="email" />
<input type="password" name="password" autoComplete="current-password" />
<button type="submit" disabled={mutation.isPending}>
Submit
</button>
</form>
);
}
Client state
Until now we did not use any useState
, useReducer
, or any other hook or library that stores and manually updates data on the client. In all the cases above it's not needed:
- URL state for global key-values in sync with the URL (browser history)
- Server state for reading data on page load
- Form state for submitting
form
requests (uncontrolled withFormData
)
Client state is only needed to manage complex interactions: multiple variables including derived values with multiple states and steps.
For these complex use cases you often need a way to control mutations and avoid unnecessary re-renders. This is when you reach for libraries like XState, Zustand, Redux.
In most cases you may not even need an external library. Hooks like
useReducer
,useContext
, anduseState
may be enough for most local and simple use cases.
State-transition function
A state management library is mostly responsible for:
- Storing and allowing access to the state
- Updating state (by avoiding excessive re-renders)
A common pattern made popular by Redux consists in a state-transition function. That's a function that takes an event and the current state, and returns the next state.
The most simple implementation of this pattern is useReducer
:
"use client";
import { useReducer } from "react";
interface State {
firstName: string;
lastName: string;
}
type Event =
| { type: "firstName.update"; value: string }
| { type: "lastName.update"; value: string };
// π Next state given current state and event (state-transition function)
const reducer = (state: State, event: Event): State => {
switch (event.type) {
case "firstName.update":
return { ...state, firstName: event.value };
case "lastName.update":
return { ...state, lastName: event.value };
}
};
export default function Page() {
const [state, dispatch] = useReducer(reducer, {
firstName: "",
lastName: "",
});
const fullName = `${state.firstName} ${state.lastName}`; // π Derived state
return (
<form>
<input
type="text"
name="firstName"
value={state.firstName}
onChange={(event) =>
dispatch({ type: "firstName.update", value: event.target.value })
}
/>
<input
type="text"
name="lastName"
value={state.lastName}
onChange={(event) =>
dispatch({ type: "lastName.update", value: event.target.value })
}
/>
<p>{fullName}</p>
<button type="submit">Submit</button>
</form>
);
}
Libraries like @xstate/store
and Redux are based on this same pattern.
State machines
For even more complex cases a state (object) and events are not enough.
State machines model different states explicitly (e.g. "opened"/"closed"). Updating a state uses the same event-driven logic of a reducer function. The main difference is that certain actions are allowed only inside certain states.
This extra level of abstraction prevents unintended actions when not allowed in a state. It gives more control of the app states, at the cost of a higher complexity.
A state machine models the full complexity of your app, that's why it's often considered harder to implement and requires more code.
The standard solution for state machines in React is XState:
"use client";
import { useMachine } from "@xstate/react";
import { createMachine } from "xstate";
const machine = createMachine({
types: {} as {
events: { type: "OPEN" } | { type: "CLOSE" };
},
initial: "closed",
states: {
closed: {
on: { OPEN: "open" },
},
open: {
on: { CLOSE: "closed" },
},
},
});
export default function Page() {
const [snapshot, send] = useMachine(machine);
if (snapshot.matches("open")) {
return (
<div>
<p>Open</p>
<button onClick={() => send({ type: "CLOSE" })}>Close</button>
</div>
);
}
return (
<div>
<p>Closed</p>
<button onClick={() => send({ type: "OPEN" })}>Open</button>
</div>
);
}
Statecharts and actors
Statecharts extend the basic model of state machines to build a complete state orchestration solution.
Statecharts add extra features including hierarchy, concurrency and communication.
XState implements the statecharts model. It also allows for multiple statecharts to communicate with each in an actor model.
An actor encapsulates the logic of a specific functionality in your app. The actor model allows sending messages between actors as a model of concurrent computation.
If you are interested in learning more about the actor model and XState you can check out: XState: Complete Getting Started Guide
State management libraries in React
Here is an extensive list of state management libraries for more complex client-state requirements: