Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 Jotai atoms 설계하기

Claude Code로 Jotai atoms를 안전하게 설계하는 법. 파생, 비동기, SSR, 테스트까지 다룹니다.

Claude Code로 Jotai atoms 설계하기

Claude Code에 “Jotai로 상태 관리 만들어줘”라고만 요청하면 범위가 너무 넓습니다. 코드는 동작할 수 있지만 atom의 크기, 서버 상태와의 경계, Provider 위치, SSR 초기값, 테스트 범위가 우연히 정해집니다. 필터, 폼 초안, 비동기 읽기, 상세 화면 UI가 섞이면 그때부터 원인을 찾기 어려워집니다.

이 글은 Jotai atoms를 범용 global store가 아니라 React의 작은 상태를 설계하는 도구로 다룹니다. 공식 Jotai atom 문서는 atom config가 값 자체를 보관하지 않고 값은 store에 존재한다고 설명합니다. 비동기는 Jotai async guide와 React 공식 <Suspense> 문서를 기준으로 봅니다. Provider와 SSR은 Jotai Provider, SSR utilities를 참고합니다.

Claude Code는 공식 overview에 설명된 것처럼 코드베이스를 읽고, 파일을 수정하고, 명령을 실행할 수 있는 agentic coding tool입니다. 그래서 요청에는 수정 범위와 검증을 적고, settings 문서permissions.deny로 비밀 파일을 제외해야 합니다. React 기본 흐름은Claude Code React 개발 가이드, 서버 데이터 경계는TanStack Query 가이드를 함께 보세요.

atom 모델을 먼저 고정하기

atom은 값 자체가 아닙니다. store 안의 값을 읽거나 쓰는 안정적인 정의입니다. 이 전제를 Claude Code에 주지 않으면 검색어, API 응답, 선택된 row, toast, 폼 초안이 하나의pageStateAtom에 들어가기 쉽습니다. 처음에는 편해 보이지만 작은 변경도 화면 전체를 건드립니다.

코드를 쓰기 전에 세 가지를 정합니다. 이 값은 UI 상태인가, 서버가 정답인 데이터인가? 멀리 떨어진 컴포넌트가 공유해야 하는가? 다른 atom에서 계산할 수 있는가? 검색어, 필터, 활성 tab, 짧은 toast, 여러 단계 폼 초안은 Jotai에 잘 맞습니다. 상품 목록, 사용자 테이블, 인증 token, 결제 session, 재고의 진실은 서버 상태 도구나 서버에 두는 것이 안전합니다.

상태Jotai에 적합피해야 할 것
폼 초안여러 단계에서 공유하는 임시 입력저장 완료된 주문 전체
관리자 필터표, 카운트, URL 동기화에서 사용전체 API 응답 저장
modal/toast멀리 있는 버튼이 열어야 함긴 오류 로그나 감사 로그
사용자 설정테마, 밀도, 접힘 상태token, 이메일, 주소, 결제 정보

Masa가 관리자 화면에서 겪은 실패는 “Jotai를 쓰자”가 목표였던 점입니다. 첫 버전은 필터, 가져온 행, 선택 행, 저장 중 상태, toast를 한 atom에 넣었습니다. 목록으로 돌아온 뒤 이전 선택 행이 남아 새 일괄 작업에 섞였습니다. API 데이터, 선택 상태, 짧은 UI 상태를 분리하자 Claude Code의 diff도 작아지고 review도 쉬워졌습니다.

설치와 최소 동작 예제

Vite 또는 Next.js React 앱에서는 먼저 Jotai를 설치합니다. 새 코드에서 atom family가 필요하다면jotai-family를 권장합니다. 현재 Jotai 문서는jotai/utilsatomFamily가 Jotai v3에서 deprecated 될 예정이라고 안내합니다.

npm i jotai jotai-family
npm i -D vitest @testing-library/react @testing-library/user-event

다음 TaskBoard는 작지만 완성된 예제입니다. primitive atom, derived atom, write-only atom이 한 흐름에 들어 있습니다.

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";

export type TaskStatus = "todo" | "doing" | "done";

export type Task = {
  id: string;
  title: string;
  status: TaskStatus;
};

const createId = () =>
  globalThis.crypto?.randomUUID?.() ?? String(Date.now());

export const tasksAtom = atom<Task[]>([
  { id: "task-1", title: "Write release note", status: "todo" },
]);

export const filterAtom = atom<TaskStatus | "all">("all");
export const draftTitleAtom = atom("");

export const visibleTasksAtom = atom((get) => {
  const filter = get(filterAtom);
  const tasks = get(tasksAtom);
  return filter === "all"
    ? tasks
    : tasks.filter((task) => task.status === filter);
});

export const taskStatsAtom = atom((get) => {
  const tasks = get(tasksAtom);
  return {
    total: tasks.length,
    done: tasks.filter((task) => task.status === "done").length,
  };
});

export const addTaskAtom = atom(null, (get, set) => {
  const title = get(draftTitleAtom).trim();
  if (!title) return;

  set(tasksAtom, (tasks) => [
    ...tasks,
    { id: createId(), title, status: "todo" },
  ]);
  set(draftTitleAtom, "");
});

export const toggleTaskAtom = atom(null, (_get, set, id: string) => {
  set(tasksAtom, (tasks) =>
    tasks.map((task) =>
      task.id === id
        ? { ...task, status: task.status === "done" ? "todo" : "done" }
        : task,
    ),
  );
});

export function TaskBoard() {
  const [draft, setDraft] = useAtom(draftTitleAtom);
  const [filter, setFilter] = useAtom(filterAtom);
  const tasks = useAtomValue(visibleTasksAtom);
  const stats = useAtomValue(taskStatsAtom);
  const addTask = useSetAtom(addTaskAtom);
  const toggleTask = useSetAtom(toggleTaskAtom);

  return (
    <section>
      <p>
        Total: {stats.total} / Done: {stats.done}
      </p>

      <label>
        New task
        <input
          value={draft}
          onChange={(event) => setDraft(event.currentTarget.value)}
        />
      </label>
      <button type="button" onClick={addTask}>
        Add
      </button>

      <select
        value={filter}
        onChange={(event) =>
          setFilter(event.currentTarget.value as TaskStatus | "all")
        }
      >
        <option value="all">All</option>
        <option value="todo">Todo</option>
        <option value="doing">Doing</option>
        <option value="done">Done</option>
      </select>

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <span>{task.title}</span>
            <button
              type="button"
              aria-label={`Mark ${task.title} done`}
              onClick={() => toggleTask(task.id)}
            >
              {task.status === "done" ? "Undo" : "Done"}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

생성 후에는 Claude Code에 다시 review를 요청합니다. derived atom이 값을 중복 저장하지 않는지, write-only atom이 업데이트 규칙을 한 곳에 모았는지, 컴포넌트가 필요한 atom만 구독하는지 확인합니다.

세 가지 이상의 실제 사용 사례

첫 번째는 관리자 필터입니다. search, status, page, sort는 표, 카운트, URL 동기화에 같이 쓰이므로 atoms에 적합합니다. 하지만 가져온 테이블 응답은 같은 atom에 넣지 않습니다. 프롬프트에는 “URL에 노출되는 조건과 UI 전용 조건을 분리하고, API 응답은 server-state layer에 둔다”고 적습니다.

두 번째는 여러 단계의 결제 또는 onboarding 폼입니다. 초안 필드, 현재 단계, 검증 결과는 Jotai에 잘 맞습니다. 제출된 주문, 결제 session, 재고 검사는 맞지 않습니다. 성공 후 여러 컴포넌트가 각각 reset하는 대신 write-only reset atom 하나로 처리합니다.

세 번째는 상세 화면 UI입니다. 펼친 row, 선택된 tab, 선택 ID, 짧은 toast queue는 작은 atoms로 나누면 영향을 받는 컴포넌트만 다시 렌더링합니다. 하나의detailPageAtom은 생성하기 쉽지만 성능과 review가 어려워집니다.

네 번째는 사용자 선호입니다. 테마, 표시 밀도, 닫은 안내는 storage helper와 잘 맞지만 개인정보는 브라우저에 저장하지 않습니다. 수익 CTA가 있는 페이지도 마찬가지입니다. CTA 열림 여부는 낮은 위험이지만 구매자 이메일과 쿠폰 기록은 서버에 둡니다.

derived atom과 write-only atom

derived atom은 다른 atom에서 값을 계산합니다. 총계, 필터 결과, validation 결과는 대개 저장하지 않고 계산합니다. write-only atom은 patch, reset, submit 전 정규화 같은 동작을 한 곳에 모읍니다.

import { atom } from "jotai";

export type CheckoutDraft = {
  email: string;
  postalCode: string;
  agreed: boolean;
};

const emptyCheckoutDraft: CheckoutDraft = {
  email: "",
  postalCode: "",
  agreed: false,
};

export const checkoutDraftAtom = atom<CheckoutDraft>(emptyCheckoutDraft);

export const checkoutErrorsAtom = atom((get) => {
  const draft = get(checkoutDraftAtom);
  const errors: Partial<Record<keyof CheckoutDraft, string>> = {};

  if (!draft.email.includes("@")) {
    errors.email = "Check the email address";
  }

  if (!/^\d{3}-?\d{4}$/.test(draft.postalCode)) {
    errors.postalCode = "Enter a seven digit postal code";
  }

  if (!draft.agreed) {
    errors.agreed = "Agreement is required";
  }

  return errors;
});

export const patchCheckoutDraftAtom = atom(
  null,
  (_get, set, patch: Partial<CheckoutDraft>) => {
    set(checkoutDraftAtom, (draft) => ({ ...draft, ...patch }));
  },
);

export const resetCheckoutDraftAtom = atom(null, (_get, set) => {
  set(checkoutDraftAtom, emptyCheckoutDraft);
});

실패 예시는checkoutErrorsAtom결과를 다른 atom에 저장하는 것입니다. 초안은 바뀌었는데 오류 snapshot은 오래된 상태로 남습니다. Claude Code에는 “현재 atoms에서 계산 가능한 값은 저장하지 말라”고 명시합니다.

async atom과 서버 상태 경계

async atom은 유용하지만 모든 data fetching의 답은 아닙니다. Jotai async read atom은 Promise를 반환할 수 있고 Suspense와 함께 fallback을 보여줄 수 있습니다. 한 UI 영역 안의 작은 읽기에 적합합니다.

import { Suspense } from "react";
import { atom, useAtomValue, useSetAtom } from "jotai";

type Profile = {
  id: string;
  name: string;
  plan: "free" | "pro";
};

export const profileIdAtom = atom("masa");

export const profileAtom = atom(async (get, { signal }) => {
  const id = get(profileIdAtom);
  const response = await fetch(`/api/profiles/${id}`, { signal });

  if (!response.ok) {
    throw new Error("Failed to load profile");
  }

  return (await response.json()) as Profile;
});

function ProfileCard() {
  const profile = useAtomValue(profileAtom);
  return <p>{profile.name} is on the {profile.plan} plan.</p>;
}

function ProfileSwitcher() {
  const setProfileId = useSetAtom(profileIdAtom);
  return (
    <button type="button" onClick={() => setProfileId("demo")}>
      Load demo user
    </button>
  );
}

export function ProfilePanel() {
  return (
    <>
      <ProfileSwitcher />
      <Suspense fallback={<p>Loading profile...</p>}>
        <ProfileCard />
      </Suspense>
    </>
  );
}

retry, stale time, pagination, optimistic mutation, invalidate가 필요하면 server-state library를 사용합니다. 좋은 프롬프트는 “Jotai는 요청 파라미터와 로컬 UI 상태만 갖고, 응답 캐시는 atoms 밖에 둔다”입니다.

atom family, SSR, Provider 주의점

각 row나 tab이 독립 UI 상태를 가져야 할 때 atom family가 유용합니다. 단, family는 캐시를 갖기 때문에 무한한 param을 제거하지 않으면 메모리 누수가 생길 수 있습니다. 새 코드는jotai-family를 쓰고, 기존jotai/utils사용은 migration note를 남깁니다.

import { atom } from "jotai";
import { atomFamily } from "jotai-family";

type RowUi = {
  expanded: boolean;
  selected: boolean;
};

export const rowUiFamily = atomFamily((id: string) =>
  atom<RowUi>({ expanded: false, selected: false }),
);

rowUiFamily.setShouldRemove((createdAt) => {
  return Date.now() - createdAt > 10 * 60_000;
});

export const removeRowUiAtom = atom(null, (_get, _set, id: string) => {
  rowUiFamily.remove(id);
});

SSR에서는 Provider 경계가 중요합니다. Provider 없이도 Jotai는 동작하지만, 요청별 초기값, subtree 격리, 테스트 주입이 필요하면 명시적 Provider가 더 안전합니다. Next.js App Router에서useHydrateAtoms는 client component에서 사용합니다.

"use client";

import { type PropsWithChildren } from "react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { tasksAtom, type Task } from "./TaskBoard";

type Props = PropsWithChildren<{
  initialTasks: Task[];
}>;

function HydrateAtoms({ initialTasks, children }: Props) {
  useHydrateAtoms(new Map([[tasksAtom, initialTasks]]));
  return children;
}

export function JotaiRequestProvider(props: Props) {
  return (
    <Provider>
      <HydrateAtoms initialTasks={props.initialTasks}>
        {props.children}
      </HydrateAtoms>
    </Provider>
  );
}

실패 모드는 같은 store에 바뀐 사용자나 tenant 값을 반복 hydrate하는 것입니다. 기본적으로 SSR hydrate는 한 store에서 한 번 주입하는 흐름에 가깝습니다. 실제 전환이 필요하면 Provider key를 바꾸거나 명시적 reset action을 둡니다.

테스트와 안전한 Claude Code 요청

Jotai Testing guide는 사용자가 컴포넌트를 조작하는 방식으로 테스트하고 Jotai를 구현 세부로 다루라고 권합니다. Claude Code 출력도 입력, 클릭, 표시 결과, reset을 기준으로 검증합니다.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider } from "jotai";
import { describe, expect, it } from "vitest";
import { TaskBoard } from "./TaskBoard";

describe("TaskBoard", () => {
  it("adds and completes a task", async () => {
    const user = userEvent.setup();

    render(
      <Provider>
        <TaskBoard />
      </Provider>,
    );

    await user.type(screen.getByLabelText("New task"), "Review atoms");
    await user.click(screen.getByRole("button", { name: "Add" }));

    expect(screen.getByText("Review atoms").textContent).toBe("Review atoms");

    await user.click(
      screen.getByRole("button", { name: "Mark Review atoms done" }),
    );

    expect(screen.getByText(/Done: 1/).textContent).toContain("Done: 1");
  });
});

요청은 이렇게 좁힙니다.

기존 React + TypeScript 화면을 읽고 Jotai v2 기준으로 상태를 정리하세요.
수정 가능한 파일은 src/features/tasks 아래로 제한합니다.
API 응답은 atoms에 저장하지 마세요.
atoms는 UI 상태와 폼 초안에만 사용하세요.
derived atoms, write-only atoms, Provider 경계, Vitest 테스트를 포함하세요.
atom family가 필요하면 jotai-family를 쓰고 cleanup을 구현하세요.
마지막에 실패 모드, 렌더링 위험, SSR 위험을 비판적으로 정리하세요.

비밀 파일은 설정에서도 제외합니다.

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Read(./build)"
    ]
  }
}

수익 CTA와 실제 검증

Jotai 글은 코드 조각에서 끝나면 약합니다. 독자는 어떤 상태를 atoms에 넣고, 어떤 상태를 TanStack Query로 보내며, 어떤 상태를 서버에 남길지 알고 싶어 합니다. 팀의 Claude Code 프롬프트,CLAUDE.md, review 규칙, 테스트 증적을 정리하려면무료 Claude Code 체크리스트에서 시작하고,Zustand 상태 관리,TanStack Query,테스트 전략을 비교하세요.

Masa가 작은 React 화면에서 이 패턴을 검증했을 때 가장 큰 효과는 atom 작성 전 상태 분류였습니다. 첫 프롬프트는 API 응답, 폼 초안, toast를 한 atom에 섞었습니다. 두 번째 프롬프트에서 “서버 데이터는 atoms 밖”, “파생값은 저장하지 않음”, “Provider와 테스트 필수”를 적자 diff가 줄었고 Vitest가 추가, 완료, reset을 확인했습니다. 특히 atom family cleanup과 SSR hydrate 경계는 공식 문서를 함께 보지 않으면 쉽게 빠집니다.

#Claude Code #Jotai #React #상태 관리 #atoms
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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