Jotai atoms com Claude Code: guia prático para React
Projete Jotai atoms com Claude Code: derivados, async, SSR, Provider, testes e prompts seguros.
Pedir ao Claude Code “adicione Jotai para gerenciar estado” é amplo demais. Ele pode gerar código que funciona, mas o tamanho dos atoms, a fronteira com estado de servidor, a posição do Provider, a hidratação SSR e os testes ficam decididos por acaso. Quando filtros, rascunhos de formulário, leituras assíncronas e UI de detalhe se misturam, essa falta de desenho aparece.
Este guia trata Jotai atoms como ferramenta de design para estados pequenos de React, não como store global genérico. A documentação oficial de Jotai atom explica que um atom config é uma definição e não guarda o valor; os valores vivem em um store. Para async usamos o Jotai async guide e a referência React de <Suspense>. Para Provider e SSR, a base é Jotai Provider e SSR utilities.
No lado do Claude Code, o overview oficial descreve uma ferramenta que lê o codebase, edita arquivos e executa comandos. Por isso, o prompt precisa de escopo e arquivos sensíveis devem ser excluídos compermissions.deny, descrito em Claude Code settings. Para o contexto React, veja o guia React com Claude Code. Para dados de servidor, compare com o guia TanStack Query.
Comece pelo modelo mental de atom
Um atom não é o valor em si. É uma definição estável que diz como ler ou escrever um valor em um store. Sem essa regra, Claude Code costuma criar umpageStateAtom enorme com busca, resposta de API, linhas selecionadas, toasts e rascunho de formulário. Parece organizado por estar em um lugar só, mas qualquer mudança afeta demais.
Antes do código, responda três perguntas. O valor é estado de UI ou verdade do servidor? Ele é compartilhado por componentes distantes ou é local? Pode ser derivado de outros atoms? Texto de busca, filtros, aba ativa, toast curto e rascunho multi etapa combinam com Jotai. Listas de produtos, tabelas de usuários, tokens, sessões de pagamento e estoque real ficam melhor no server state ou no servidor.
| Estado | Bom uso de Jotai | Evitar |
|---|---|---|
| Rascunho de formulário | Compartilhado entre etapas | Guardar pedido completo já salvo |
| Filtros admin | Tabela, contador e URL usam | Guardar resposta API inteira |
| Modal e toast | Acionados por botões distantes | Logs longos ou auditoria |
| Preferências | Tema, densidade, aviso fechado | Token, email, endereço, pagamento |
Masa errou em uma tela administrativa ao tratar “usar Jotai” como objetivo. A primeira versão guardava filtros, linhas carregadas, seleção, estado de salvamento e toasts juntos. Ao voltar para a lista, uma seleção antiga afetou uma nova ação em massa. Separar dados de servidor, seleção e UI curta reduziu o diff do Claude Code e facilitou a revisão.
Instalação e slice mínimo
Em uma app React com Vite ou Next.js, instale Jotai primeiro. Se precisar de atom families em código novo, prefirajotai-family, pois a documentação atual marcaatomFamily dejotai/utils como deprecated para Jotai v3.
npm i jotai jotai-family
npm i -D vitest @testing-library/react @testing-library/user-event
Este painel de tarefas é pequeno, mas completo. Ele inclui atoms primitivos, atoms derivados e atoms write-only em um exemplo copiável.
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>
);
}
Depois de gerar, peça uma revisão crítica: valores derivados não devem ser duplicados, write-only atoms devem concentrar regras de atualização e componentes devem assinar apenas os atoms que renderizam.
Casos de uso para definir granularidade
O primeiro caso é uma barra de filtros administrativa. search, status, page esort podem ficar em atoms quando tabela, contador e URL dependem deles. A resposta completa da API não deve ficar ali. Escreva no prompt: “separe condições visíveis na URL de estado só de UI, e mantenha respostas API na camada de server state”.
O segundo caso é checkout ou onboarding multi etapa. Campos de rascunho, etapa atual e validação combinam com Jotai. Pedidos enviados, sessões de pagamento e checagem de estoque não. Um reset atom write-only após sucesso é mais seguro do que limpar campos em vários componentes.
O terceiro caso é estado de UI de uma página de detalhe. Linhas expandidas, aba ativa, ID selecionado e uma fila curta de toasts podem ser atoms pequenos, reduzindo re-render. Um únicodetailPageAtom é rápido de gerar, mas difícil de revisar.
O quarto caso são preferências. Tema, densidade e avisos dispensados podem usar helpers de storage. Dados privados não devem ser persistidos no navegador. Em páginas monetizadas, estado de abertura de CTA é baixo risco; emails de compradores e histórico de cupons ficam no servidor.
Atoms derivados e write-only
Atoms derivados calculam valores de outros atoms. Totais, listas filtradas e resultados de validação normalmente são calculados, não armazenados. Write-only atoms centralizam patch, reset ou normalização antes de salvar.
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);
});
O erro comum é salvar o resultado decheckoutErrorsAtomem outro atom. O rascunho muda, mas o snapshot de erros fica antigo. A regra para Claude Code deve ser: “não armazene valores derivados dos atoms atuais”.
Async atoms e fronteira com server state
Async atoms são úteis, mas não substituem toda estratégia de busca de dados. Um async read atom pode retornar uma Promise, e Suspense mostra fallback enquanto carrega. Isso funciona bem para leituras pequenas em uma área de 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>
</>
);
}
Se você precisa de retry, stale time, paginação, mutation otimista ou invalidate, use uma biblioteca de server state. Jotai pode guardar parâmetros de request e UI local; cache de resposta fica fora.
Atom family, SSR e Provider
Atom family ajuda quando cada linha ou aba precisa de seu próprio estado de UI. O risco é crescimento de cache. A documentação oficial explica que families guardam atoms por parâmetro; parâmetros ilimitados sem limpeza podem gerar vazamento de memória. Em código novo usejotai-family.
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);
});
Em SSR, Provider é uma fronteira importante. Jotai funciona sem Provider explícito, mas valores iniciais por request, isolamento de subtree e testes ficam mais claros com Provider. No Next.js App Router,useHydrateAtoms deve estar em um 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>
);
}
O erro é tentar hidratar o mesmo store repetidamente quando usuário ou tenant muda. Por padrão, a hidratação é um passo inicial por store. Para troca real, remonte o Provider com outra key ou crie uma ação de reset.
Testes e prompts seguros
O guia de testes do Jotai recomenda testar como o usuário interage com componentes e tratar Jotai como detalhe de implementação. Para aceitar a saída do Claude Code, cubra entrada, clique, resultado visível e 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");
});
});
Use um prompt com escopo:
Leia a tela React + TypeScript existente e reorganize o estado com Jotai v2.
Edite somente arquivos em src/features/tasks.
Não armazene respostas API em atoms.
Use atoms apenas para estado de UI e rascunhos de formulário.
Inclua atoms derivados, write-only atoms, Provider boundary e testes Vitest.
Se atom family for necessária, use jotai-family com cleanup.
Termine com revisão crítica de falhas, renders e riscos SSR.
Exclua segredos também na configuração:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Read(./build)"
]
}
}
CTA e verificação prática
Um artigo de Jotai não deve terminar em snippets. O leitor precisa de uma regra: o que vai para atoms, o que vai para TanStack Query e o que fica no servidor. Para organizar prompts Claude Code,CLAUDE.md, regras de revisão e evidências de teste, comece pelachecklist gratuita de Claude Code e compareZustand,TanStack Query eestratégias de teste.
Masa testou este padrão em uma pequena tela React. O maior ganho veio antes de escrever atoms. O primeiro prompt misturou resposta API, rascunho de formulário e toast em um atom. O segundo dizia “dados de servidor ficam fora”, “valores derivados não são armazenados” e “Provider mais testes são obrigatórios”. O diff diminuiu, Vitest cobriu adicionar, concluir e resetar, e a revisão encontrou dois riscos fáceis de esquecer: cleanup de atom family e fronteira de hidratação SSR.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.