Tips & Tricks (Updated: 6/2/2026)

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 with Claude Code: A Practical Refactor Guide

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.

NeedFirst choiceReach for a library when
Input, tab, modaluseStateAlmost never
Multi-action screenuseReducerThe reducer is reused across routes
Deep prop passingContextHigh-frequency updates cause broad re-renders
Cart or editor draftZustandMany distant components update it
Theme and density settingsJotaiMany small preferences are combined
Products, orders, profile APITanStack QueryYou 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.

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.

#Claude Code #React #state management #Zustand #frontend
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.