React State Management with Claude Code: A Practical Refactor Guide
Use Claude Code to organize React state with Context, Zustand, Jotai, TanStack Query, tests, and safer prompts.
React state management becomes messy when every changing value is treated the same. A modal flag, a todo reducer, a shopping cart, a theme preference, and a product list fetched from an API all look like “state”, but they do not have the same owner or lifetime.
Claude Code can help clean this up, but only if the instruction is specific. “Refactor state management” is too broad. It often leads to a bigger global store, duplicated API caches, or a library migration without tests. A better workflow is to ask Claude Code to map the state first, separate client state from server state, then move one small slice at a time.
This guide gives you a beginner-friendly decision model, copy-pasteable React and TypeScript examples, four practical use cases, test examples, safe refactor prompts, official links, and a short note from actually trying the workflow.
Split state before choosing a library
Start by naming the kind of state. That decision is more important than the library choice.
flowchart TD
A["React state management"] --> B["Local UI state"]
A --> C["Shared client state"]
A --> D["Persisted preferences"]
A --> E["Server state"]
B --> B1["input, modal, tab"]
C --> C1["cart, wizard, editor"]
D --> D1["theme, density, locale"]
E --> E1["products, orders, profile from API"]
Local UI state belongs close to the component. Use useState for a simple value and useReducer when the same screen has several actions. Shared client state is state created in the browser and read by distant parts of the tree, such as a cart drawer and a header badge. Persisted preferences survive reloads, such as theme or compact mode. Server state lives outside the browser and can change without your app knowing, so it needs fetching, cache invalidation, retry, and error handling.
The React docs on Managing State emphasize lifting state up, choosing a good state structure, and scaling with reducer plus context. TanStack Query’s v5 overview draws the important line between client state and server state. Use those two ideas before adding anything else.
When React Context is enough
React built-ins are enough more often than teams expect. If a state value is only used by one screen, starts from user input, and does not need to survive a page reload, keep it in React. If the update logic has named actions, use a reducer. If distant children need access, combine reducer and context.
| Need | First choice | Reach for a library when |
|---|---|---|
| Input, tab, modal | useState | Almost never |
| Multi-action screen | useReducer | The reducer is reused across routes |
| Deep prop passing | Context | High-frequency updates cause broad re-renders |
| Cart or editor draft | Zustand | Many distant components update it |
| Theme and density settings | Jotai | Many small preferences are combined |
| Products, orders, profile API | TanStack Query | You need cache, refetch, retry, invalidation |
A simple rule helps: if the value is used in fewer than three places and is not fetched from an API, avoid a new state library. Ask Claude Code to justify every migration in those terms.
Use case 1: Todo state with reducer and context
A todo list is a good first example because it has real actions but no server sync yet. The reducer makes the business actions explicit, and context lets nested components read or dispatch without prop drilling.
import {
createContext,
useContext,
useMemo,
useReducer,
type Dispatch,
type ReactNode,
} from "react";
export type Todo = {
id: string;
title: string;
done: boolean;
};
type TodoAction =
| { type: "added"; title: string }
| { type: "toggled"; id: string }
| { type: "deleted"; id: string };
export function todoReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "added":
return [
...todos,
{ id: crypto.randomUUID(), title: action.title.trim(), done: false },
];
case "toggled":
return todos.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo,
);
case "deleted":
return todos.filter((todo) => todo.id !== action.id);
default:
return todos;
}
}
const TodoStateContext = createContext<Todo[] | null>(null);
const TodoDispatchContext = createContext<Dispatch<TodoAction> | null>(null);
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, []);
const stateValue = useMemo(() => todos, [todos]);
return (
<TodoStateContext.Provider value={stateValue}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
export function useTodos() {
const value = useContext(TodoStateContext);
if (value === null) throw new Error("useTodos must be used in TodoProvider");
return value;
}
export function useTodoDispatch() {
const value = useContext(TodoDispatchContext);
if (value === null) {
throw new Error("useTodoDispatch must be used in TodoProvider");
}
return value;
}
This structure is easy for Claude Code to modify safely. You can ask for “add an edited action”, “reject empty titles”, or “write tests for the reducer” without changing the rest of the app. It is also a good baseline before deciding whether a store library is actually needed.
Use case 2: Cart state with Zustand
A shopping cart usually crosses component boundaries. The product page adds items, the header shows a count, the cart drawer updates quantity, and checkout reads the final list. This is where Zustand can be useful: it creates a small hook-based store without wrapping every screen in custom providers.
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type CartItem = {
productId: string;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
currency: "JPY" | "USD";
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (productId: string) => void;
setQuantity: (productId: string, quantity: number) => void;
clear: () => void;
total: () => number;
};
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
currency: "USD",
addItem: (item) =>
set((state) => {
const current = state.items.find(
(entry) => entry.productId === item.productId,
);
if (current) {
return {
items: state.items.map((entry) =>
entry.productId === item.productId
? { ...entry, quantity: entry.quantity + 1 }
: entry,
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.productId !== productId),
})),
setQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.productId === productId
? { ...item, quantity: Math.max(1, quantity) }
: item,
),
})),
clear: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{
name: "cart-v1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
items: state.items,
currency: state.currency,
}),
},
),
);
The pitfall is trusting the browser too much. A persisted cart is convenient, but the server must still validate price, stock, discounts, tax, and payment. When prompting Claude Code, say which fields may be persisted and which fields must be confirmed by the API.
Use case 3: Settings with Jotai
Settings are often small and independent: theme, layout density, sidebar open state, preview mode, locale. Jotai is a good fit when you want tiny atoms and derived values instead of one large settings object.
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const themeAtom = atomWithStorage<"light" | "dark">(
"settings.theme",
"light",
);
export const densityAtom = atomWithStorage<"comfortable" | "compact">(
"settings.density",
"comfortable",
);
export const sidebarOpenAtom = atom(true);
export const settingsLabelAtom = atom((get) => {
const theme = get(themeAtom) === "dark" ? "dark theme" : "light theme";
const density =
get(densityAtom) === "compact" ? "compact layout" : "comfortable layout";
return `${theme}, ${density}`;
});
export const resetSettingsAtom = atom(null, (_get, set) => {
set(themeAtom, "light");
set(densityAtom, "comfortable");
set(sidebarOpenAtom, true);
});
Jotai is not a dumping ground for every value. Keep atoms small, use derived atoms for display values, and do not use atoms as a replacement for server-state caching. If an atom starts containing fetch status, API data, and invalidation rules, it is probably the wrong owner.
Use case 4: Products and orders with TanStack Query
Products, order history, and user profile data are server state. They are loaded asynchronously, can become stale, and may be changed by another device or user. TanStack Query handles this class of work: query keys, loading state, cache, retries, refetching, mutations, and invalidation.
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { ReactNode } from "react";
type Product = {
id: string;
name: string;
price: number;
stock: number;
};
const queryClient = new QueryClient();
export function ProductsProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
async function fetchProducts(category: string): Promise<Product[]> {
const response = await fetch(`/api/products?category=${category}`);
if (!response.ok) throw new Error("Failed to fetch products");
return response.json() as Promise<Product[]>;
}
async function addCartLine(productId: string) {
const response = await fetch("/api/cart/lines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
if (!response.ok) throw new Error("Failed to add cart line");
return response.json();
}
export function useProducts(category: string) {
return useQuery({
queryKey: ["products", category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});
}
export function useAddCartLine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addCartLine,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
}
The practical boundary is clear: Zustand can hold the user’s temporary cart intent, while TanStack Query should fetch the server’s cart, products, orders, and checkout status. If Claude Code merges those responsibilities, ask it to split them again.
Safe Claude Code prompts for refactoring
Claude Code’s current docs describe an agentic loop of gathering context, taking action, and verifying results. Use that workflow directly in your prompts. First ask for a survey, not edits.
Inspect this React app's state management. Do not edit files yet.
Goals:
- separate client state from server state
- identify state that can stay in useState/useReducer/Context
- identify places where Zustand, Jotai, or TanStack Query would help
Return:
- a state inventory table
- duplicated or derived state that should be removed
- API data currently stored as client state
- a safe migration order with tests
Then implement one slice at a time.
Apply only the todo reducer refactor from the plan.
Constraints:
- do not change route behavior or visible copy
- do not add new dependencies
- update or add tests for the reducer
- report changed files and verification commands
For carts and API data, add domain constraints: “do not persist auth tokens”, “price must be confirmed by the server”, “use TanStack Query for server data”, and “keep the public component API unchanged”. These constraints matter more than clever library usage.
Tests that keep the refactor honest
Test pure logic first. A reducer test is cheap and catches many mistakes before you start browser testing.
import { describe, expect, it, vi } from "vitest";
import { todoReducer, type Todo } from "./TodoProvider";
describe("todoReducer", () => {
it("adds a todo with a generated id", () => {
vi.spyOn(crypto, "randomUUID").mockReturnValue("todo-1");
const result = todoReducer([], { type: "added", title: "Write tests" });
expect(result).toEqual([
{ id: "todo-1", title: "Write tests", done: false },
]);
});
it("toggles a todo", () => {
const initial: Todo[] = [{ id: "todo-1", title: "Ship", done: false }];
const result = todoReducer(initial, { type: "toggled", id: "todo-1" });
expect(result[0].done).toBe(true);
});
});
For TanStack Query, follow the official testing guide: create a fresh QueryClient per test and avoid sharing cache between tests. For Zustand and Jotai, test the store or atom behavior separately from the full page, then do one integration check for the actual user path.
Pitfalls to avoid
The first pitfall is putting everything in a global store. Modal flags, one-off form fields, and temporary input values usually belong near the component. Global state should be earned.
The second pitfall is storing server state in a client store and rebuilding cache logic by hand. Once you add loading, error, retry, stale data, pagination, and invalidation, you are reimplementing TanStack Query poorly.
The third pitfall is duplicated derived state. If you store both items and totalPrice, you can forget to update one. Prefer selectors, derived atoms, or functions.
The fourth pitfall is over-persisting. Do not put access tokens, sensitive profile data, or permission data into localStorage just because a middleware makes it easy.
The fifth pitfall is accepting Claude Code’s diff without verification. State bugs often show up after reload, in another tab, when an API request fails, or during a slow network. Run tests, build, and manually check the target flow.
Links, CTA, and tested result
Official references: React Managing State, Zustand create, Zustand persist, Jotai docs, TanStack Query overview, and Claude Code best practices.
For the prompting side, continue with the better prompts guide and Claude Code productivity tips. If you want reusable prompts, setup rules, and team rollout material, use the products page. If your team needs help applying this to a real repository, the training and consultation page is the natural next step.
After trying this workflow, the biggest improvement came from moving server state first. API data moved to TanStack Query, the Zustand store became smaller, and Jotai stayed focused on preferences. Asking Claude Code for an inventory before implementation produced smaller diffs and made review much easier.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.