Use Cases (Mis à jour: 02/06/2026)

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.

Jotai atoms avec Claude Code : guide pratique React

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.

ÉtatBon usage JotaiÀ éviter
Brouillon de formulairePartagé entre étapesStocker une commande déjà persistée
Filtres adminTable, compteur et URL les lisentStocker une réponse API complète
Modal et toastDéclenchés depuis des boutons éloignésLogs longs ou audit
PréférencesThè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.

#Claude Code #Jotai #React #gestion d'état #atoms
Gratuit

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.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.