Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 React 상태 관리 정리하기: Context부터 TanStack Query까지

Claude Code로 React 상태 관리를 정리하는 기준과 코드 예제. Context, Zustand, Jotai, TanStack Query를 비교합니다.

Claude Code로 React 상태 관리 정리하기: Context부터 TanStack Query까지

React 상태 관리는 라이브러리를 많이 쓰면 해결되는 문제가 아닙니다. 입력값, 모달 열림 여부, 장바구니, 사용자 설정, API에서 받아온 상품 목록은 모두 state처럼 보이지만 소유자와 수명이 다릅니다.

Claude Code에게 “상태 관리 정리해줘”라고만 말하면 전역 store가 커지거나, 서버 데이터를 클라이언트 store에 복사하거나, 테스트 없이 라이브러리만 늘어날 수 있습니다. 더 좋은 방식은 먼저 상태를 분류하게 하고, React 기본 기능으로 충분한 부분과 Zustand, Jotai, TanStack Query가 필요한 부분을 나누는 것입니다.

이 글은 초보자도 따라갈 수 있도록 client state와 server state의 차이, todo, cart, settings, product list 네 가지 사례, 테스트, 안전한 프롬프트, 공식 링크와 수익화 CTA까지 한 번에 정리합니다.

라이브러리보다 상태 분류가 먼저입니다

먼저 이 상태가 어디에 속하는지 정합니다. 현재 컴포넌트의 UI 상태인지, 브라우저 안에서 공유되는 상태인지, 새로고침 뒤에도 남아야 하는 설정인지, 서버가 진짜 소유자인 데이터인지가 핵심입니다.

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는 useStateuseReducer로 충분한 경우가 많습니다. 여러 컴포넌트가 멀리 떨어져 같은 값을 읽고 바꾸면 Context나 Zustand를 고려합니다. 테마, 화면 밀도, 사이드바 같은 작은 설정은 Jotai와 잘 맞습니다. API 데이터처럼 서버가 소유하는 값은 TanStack Query 같은 서버 상태 도구에 맡기는 편이 안전합니다.

React 기본 기능으로 충분한 경우

React 공식 Managing State는 state 구조를 단순하게 만들고, 필요할 때 끌어올리고, 복잡해지면 reducer와 context를 조합하는 흐름을 설명합니다. Claude Code에게도 이 순서를 기준으로 판단하게 해야 합니다.

필요먼저 선택할 것라이브러리를 고민할 때
input, tab, modaluseState거의 필요 없음
액션이 많은 화면useReducerreducer가 여러 route로 퍼질 때
깊은 prop 전달Context업데이트가 잦아 재렌더가 커질 때
cart, editor draftZustand멀리 떨어진 컴포넌트가 함께 갱신할 때
theme, densityJotai작은 설정이 많고 파생값이 필요할 때
products, orders APITanStack Querycache, retry, refetch, invalidation이 필요할 때

간단한 기준은 네 가지입니다. 같은 값이 세 곳 이상에서 수정되는가, 페이지를 넘어 쓰이는가, 새로고침 뒤에도 남아야 하는가, API에서 오는가. 이 질문에 모두 아니면 React 기본 상태로 시작하는 것이 낫습니다.

사례1: todo는 useReducer와 Context로 시작

todo는 add, toggle, delete라는 명확한 액션이 있습니다. 아직 서버 동기화가 없다면 외부 라이브러리보다 reducer가 먼저입니다.

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;
}

이 구조는 Claude Code에게 작은 변경을 맡기기 좋습니다. “빈 title을 막아라”, “edited action을 추가해라”, “reducer 테스트를 작성해라”처럼 요청할 수 있고, diff도 읽기 쉽습니다.

사례2: 장바구니는 Zustand로 공유

장바구니는 상품 상세, header badge, cart drawer, checkout이 모두 읽고 씁니다. Zustand는 hook 기반 store를 만들 수 있고, persist middleware로 새로고침 뒤에도 일부 값을 유지할 수 있습니다.

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,
      }),
    },
  ),
);

주의할 점은 브라우저의 cart를 결제 진실로 믿지 않는 것입니다. 가격, 재고, 할인, 세금, 결제 가능 여부는 서버에서 다시 확인해야 합니다. Claude Code에게는 “persist할 필드”와 “서버가 확인해야 할 필드”를 꼭 분리해서 말합니다.

사례3: 설정은 Jotai atom으로 작게 나누기

테마, density, sidebar, preview mode처럼 작은 설정은 하나의 거대한 settings object보다 atom 단위가 읽기 쉽습니다. Jotai는 작은 atom과 derived atom을 만들기 좋습니다.

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를 모든 문제에 쓰면 atom이 너무 많아집니다. 설정과 파생 표시값에는 좋지만, API cache와 mutation 규칙은 TanStack Query에 남겨야 합니다.

사례4: 상품과 주문은 TanStack Query

상품 목록, 주문 내역, 프로필은 서버 상태입니다. TanStack Query v5는 서버 상태의 fetching, caching, syncing, updating을 다루는 라이브러리입니다.

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"] });
    },
  });
}

핵심은 경계입니다. 임시 cart 의도는 Zustand가 맡을 수 있지만, 서버의 cart, product, order, checkout status는 TanStack Query로 다시 받아야 합니다.

Claude Code 프롬프트와 테스트

Claude Code 공식 How Claude Code works는 context 수집, action, verification의 반복을 설명합니다. 상태 관리 refactor도 먼저 조사시키고, 한 slice만 수정하게 해야 합니다.

이 React 앱의 상태 관리를 조사하세요. 아직 파일을 수정하지 마세요.

목표:
- client state와 server state를 분리
- useState/useReducer/Context로 충분한 부분 표시
- Zustand/Jotai/TanStack Query가 필요한 부분과 이유 표시

출력:
- state inventory table
- 중복되거나 파생된 state
- API 데이터를 client state로 저장한 곳
- 안전한 migration 순서와 테스트 계획

테스트는 reducer처럼 순수 로직부터 시작합니다.

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);
  });
});

TanStack Query는 공식 testing guide처럼 테스트마다 새 QueryClient를 만들어 cache가 새지 않게 합니다.

흔한 함정, 링크, CTA

흔한 함정은 다섯 가지입니다. 모든 state를 global store에 넣는 것, server state를 Zustand에 넣고 cache를 직접 만드는 것, itemstotalPrice처럼 파생값을 중복 저장하는 것, localStorage에 민감 정보를 저장하는 것, Claude Code의 diff를 test/build/manual check 없이 받아들이는 것입니다.

공식 링크는 React Managing State, Zustand persist, Jotai, TanStack Query overview, Claude Code best practices를 확인하세요. 이어서 better prompts guideClaude Code productivity tips를 읽으면 프롬프트 품질을 높일 수 있습니다.

팀 표준으로 만들려면 CLAUDE.md, review rule, test command, migration order가 필요합니다. 개인은 제품과 템플릿에서 시작하고, 실제 repository에 적용하는 팀은 training / consultation을 검토하세요.

실제로 이 흐름을 써 보니, server state를 먼저 TanStack Query로 옮겼을 때 효과가 가장 컸습니다. API 데이터가 빠지자 Zustand store가 작아졌고, Jotai도 설정에만 집중할 수 있었습니다. Claude Code에게 바로 구현을 맡기기보다 state inventory와 migration plan을 먼저 요구하는 편이 review하기 쉬웠습니다.

#Claude Code #React #상태 관리 #Zustand #frontend
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.