Jotai atoms avec Claude Code : guide pratique React
Concevez des atoms Jotai avec Claude Code : dérivés, async, SSR, Provider, tests et prompts sûrs.
Demander à Claude Code “ajoute Jotai pour gérer l’état” est trop vague. Vous obtiendrez peut-être du code qui fonctionne, mais la taille des atoms, la frontière avec l’état serveur, la position du Provider, l’hydratation SSR et les tests seront décidés par hasard. Quand les filtres, les brouillons de formulaire, les lectures asynchrones et l’UI de détail se croisent, la dette devient visible.
Cet article présente les Jotai atoms comme un outil de conception pour le petit état React, pas comme un store global générique. La documentation officielle Jotai atom explique qu’un atom config est une définition et ne contient pas la valeur; les valeurs vivent dans un store. Pour l’asynchrone, les références sont Jotai async guide et la documentation React de <Suspense>. Pour Provider et SSR, nous nous appuyons sur Jotai Provider et SSR utilities.
Côté Claude Code, l’overview officiel décrit un outil capable de lire un codebase, modifier des fichiers et lancer des commandes. Il faut donc borner la demande et exclure les secrets avecpermissions.deny, documenté dans Claude Code settings. Pour le contexte React global, consultez le guide React avec Claude Code. Pour les données serveur, comparez avec le guide TanStack Query.
Fixer le modèle mental d’abord
Un atom n’est pas la valeur elle-même. C’est une définition stable qui indique comment lire ou écrire une valeur dans un store. Si Claude Code ne reçoit pas cette contrainte, il crée souvent un énormepageStateAtom avec recherche, réponse API, lignes sélectionnées, toasts et brouillon de formulaire. Tout semble centralisé, mais chaque modification déclenche trop de surface.
Avant d’écrire du code, répondez à trois questions. La valeur est-elle un état d’UI ou une vérité serveur? Est-elle partagée par des composants éloignés ou locale à un composant? Peut-elle être dérivée d’autres atoms? Texte de recherche, filtres, onglet actif, toast court et brouillon multi étape vont bien dans Jotai. Listes produits, tables utilisateurs, tokens, sessions de paiement et stock réel appartiennent plutôt à une couche server state ou au serveur.
| État | Bon usage Jotai | À éviter |
|---|---|---|
| Brouillon de formulaire | Partagé entre étapes | Stocker une commande déjà persistée |
| Filtres admin | Table, compteur et URL les lisent | Stocker une réponse API complète |
| Modal et toast | Déclenchés depuis des boutons éloignés | Logs longs ou audit |
| Préférences | Thème, densité, panneau fermé | Token, email, adresse, paiement |
Masa a rencontré ce problème sur un écran d’administration: l’objectif était “utiliser Jotai”, pas inventorier l’état. La première version stockait filtres, lignes chargées, sélection, état de sauvegarde et toasts ensemble. Après navigation, une ancienne sélection affectait une nouvelle action groupée. Séparer les données serveur, la sélection et l’UI courte a réduit le diff Claude Code et rendu la revue plus nette.
Installation et slice minimal
Dans une app React Vite ou Next.js, installez Jotai. Pour de nouvelles atom families, préférezjotai-family: la documentation actuelle indique queatomFamily depuisjotai/utils est déprécié pour Jotai v3.
npm i jotai jotai-family
npm i -D vitest @testing-library/react @testing-library/user-event
Ce tableau de tâches est petit mais complet. Il contient des atoms primitifs, des atoms dérivés et des atoms write-only dans un exemple copiable.
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>
);
}
Après génération, demandez une revue critique: les valeurs dérivées ne doivent pas être dupliquées, les actions write-only doivent centraliser les règles, et les composants ne doivent s’abonner qu’aux atoms rendus.
Trois cas d’usage concrets
Le premier cas est une barre de filtres admin. search, status, page etsort peuvent vivre dans des atoms s’ils alimentent la table, le compteur et la synchronisation URL. La réponse API complète ne doit pas y aller. Dites à Claude Code: “sépare les conditions visibles dans l’URL de l’état UI, et laisse les réponses API dans la couche server state”.
Le deuxième cas est un checkout ou onboarding multi étape. Champs de brouillon, étape courante et validation vont bien dans Jotai. Commandes soumises, sessions de paiement et contrôle stock n’y vont pas. Un atom write-only pour reset après succès est plus fiable que plusieurs resets dispersés.
Le troisième cas est l’état UI d’une page détail. Lignes ouvertes, onglet actif, ID sélectionné et courte file de toasts peuvent être séparés pour limiter les re-renders. Un seuldetailPageAtom se génère vite, mais se teste mal.
Le quatrième cas est la préférence utilisateur. Thème, densité et indice fermé peuvent être persistés. Les données privées ne doivent pas aller dans le navigateur. Sur une page monétisée, l’état d’ouverture du CTA est peu risqué; les emails d’acheteurs et l’historique de coupons restent côté serveur.
Atoms dérivés et write-only
Les atoms dérivés calculent depuis d’autres atoms. Totaux, listes filtrées et validations se calculent, ils ne se stockent pas. Les write-only atoms rassemblent patch, reset ou normalisation avant sauvegarde.
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);
});
L’échec courant consiste à stocker le résultat decheckoutErrorsAtomdans un autre atom. Le brouillon change, mais l’instantané d’erreurs reste ancien. La règle à donner à Claude Code: “ne stocke pas une valeur qui se déduit des atoms actuels”.
Async atoms et limite server state
Les async atoms sont utiles, mais ne remplacent pas toute la stratégie de fetch. Un async read atom peut retourner une Promise, et Suspense affiche un fallback pendant le chargement. C’est adapté aux petites lectures dans une zone 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>
</>
);
}
Si vous avez besoin de retry, stale time, pagination, mutation optimiste ou invalidation, utilisez une librairie server state. Jotai peut garder les paramètres de requête et l’UI locale; le cache des réponses reste dehors.
Atom family, SSR et Provider
Atom family est utile quand chaque ligne ou onglet a son état UI. Le danger est la croissance du cache. La documentation explique que la famille garde les atoms par paramètre; des paramètres illimités sans suppression peuvent créer des fuites mémoire. En nouveau code, utilisezjotai-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);
});
En SSR, le Provider est une frontière importante. Jotai peut fonctionner sans Provider explicite, mais un Provider rend les valeurs initiales par requête, l’isolation de sous-arbre et les tests plus sûrs. Dans Next.js App Router,useHydrateAtoms doit être dans un composant client.
"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>
);
}
Le piège est de réhydrater sans cesse le même store quand l’utilisateur ou le tenant change. Par défaut, l’hydratation d’un store est pensée comme une injection initiale. Pour un vrai changement, remontez le Provider avec une nouvelle key ou ajoutez une action reset.
Tests et prompts sûrs
Le guide de test Jotai conseille de tester comme l’utilisateur interagit avec les composants et de traiter Jotai comme un détail d’implémentation. Pour valider Claude Code, testez saisie, clic, résultat visible et 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");
});
});
Utilisez un prompt borné:
Lis l'écran React + TypeScript existant et réorganise l'état avec Jotai v2.
Ne modifie que les fichiers sous src/features/tasks.
Ne stocke pas les réponses API dans des atoms.
Utilise les atoms pour l'état UI et les brouillons de formulaire.
Ajoute atoms dérivés, write-only atoms, Provider boundary et tests Vitest.
Si atom family est nécessaire, utilise jotai-family avec cleanup.
Termine par une revue critique des échecs, renders et risques SSR.
Excluez aussi les secrets:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Read(./build)"
]
}
}
CTA et vérification terrain
Un article Jotai ne doit pas s’arrêter aux snippets. Le lecteur veut une règle de décision: ce qui va dans atoms, ce qui part dans TanStack Query, et ce qui reste serveur. Pour organiser prompts Claude Code,CLAUDE.md, règles de review et preuves de test, commencez par lachecklist Claude Code gratuite, puis comparezZustand,TanStack Query ettests Claude Code.
Masa a testé ce modèle sur un petit écran React. Le plus grand gain est venu avant d’écrire les atoms. Le premier prompt mélangeait réponse API, brouillon de formulaire et toast dans un seul atom. Le second disait: “les données serveur restent dehors”, “les valeurs dérivées ne sont pas stockées”, “Provider et tests sont obligatoires”. Le diff a diminué, Vitest a couvert ajout, completion et reset, et la revue a trouvé deux risques faciles à oublier: cleanup d’atom family et frontière d’hydratation SSR.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.