Jotai 2 — Gestion d'état atomique pour React et Next.js : de zéro à la production

Équipe Noqta
Par Équipe Noqta ·

Chargement du lecteur de synthèse vocale...

Une approche primitive et ascendante de l'état React. Jotai traite l'état comme une collection d'atomes — de petites unités indépendantes qui se composent pour former l'état de votre application. Pas de reducers, pas de providers par défaut, pas de boilerplate de selectors. Dans ce tutoriel, vous construirez un panier d'achat concret avec Next.js 15 App Router et apprendrez tous les patterns Jotai nécessaires pour la production.

Ce que vous allez apprendre

À la fin de ce tutoriel, vous saurez :

  • Configurer Jotai 2 dans un projet Next.js 15 App Router avec TypeScript
  • Créer des atomes primitifs, dérivés et en écriture seule
  • Gérer les atomes asynchrones avec Suspense et l'utilitaire loadable
  • Persister l'état dans localStorage avec atomWithStorage
  • Hydrater en toute sécurité l'état rendu côté serveur via useHydrateAtoms
  • Bâtir des familles d'atomes pour des collections dynamiques
  • Déboguer avec Jotai DevTools
  • Livrer un panier d'achat de production avec une véritable architecture atomique

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé (node --version)
  • De l'expérience en TypeScript (types, generics, hooks)
  • Une familiarité avec les bases du Next.js App Router (layouts, pages, composants serveur/client)
  • Une compréhension basique de l'état React (useState, useContext)

Pourquoi Jotai en 2026 ?

La gestion d'état React a évolué en trois grandes familles : à base de store (Redux, Zustand), atomique (Jotai, Recoil) et à base de signaux (Preact Signals, Solid). Jotai est l'option atomique la plus populaire et s'accorde magnifiquement avec React 19 et Next.js 15.

CaractéristiqueJotaiZustandRedux ToolkitContext API
Taille du bundle~3 KB~1 KB~12 KB0 KB (intégré)
Modèle mentalAtomique (ascendant)Store (descendant)Store + reducersArborescent
BoilerplateMinimalMinimalModéréÉlevé à l'échelle
Qualité TypeScriptExcellenteExcellenteBonneManuelle
Primitives asyncDe première classeManuellesVia thunks/sagasManuelles
Support SSRIntégréIntégréConfiguration en plusIntégré
Contrôle du re-renderPar atome (automatique)Via selectorsVia selectorsArbre entier

Le modèle atomique brille quand votre graphe d'état possède de nombreuses tranches indépendantes qui se dérivent les unes des autres. Changez un atome et seuls les composants qui le lisent réellement se rafraîchissent — pas de selectors, pas de gymnastique de memoization.


Étape 1 : Configuration du projet

Créez un nouveau projet Next.js 15 avec TypeScript et Tailwind :

npx create-next-app@latest jotai-cart --typescript --tailwind --app --src-dir
cd jotai-cart

Installez Jotai et son extension DevTools :

npm install jotai
npm install -D jotai-devtools

Votre structure ressemblera à ceci :

jotai-cart/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── atoms/          # Les atomes vivent ici
│   ├── components/
│   └── lib/
├── package.json
└── tsconfig.json

Créez le dossier atoms/ — les atomes y vivront, organisés par domaine.

mkdir -p src/atoms src/components

Étape 2 : Votre premier atome

La brique de base de Jotai est l'atom. Considérez un atome comme un morceau d'état que n'importe quel composant peut lire et écrire, sans boilerplate de provider.

Créez src/atoms/counter.ts :

import { atom } from "jotai";
 
export const countAtom = atom(0);

C'est tout — pas de store, pas de slice, pas de reducer. Utilisez-le dans un composant :

// src/components/Counter.tsx
"use client";
 
import { useAtom } from "jotai";
import { countAtom } from "@/atoms/counter";
 
export function Counter() {
  const [count, setCount] = useAtom(countAtom);
 
  return (
    <div className="flex items-center gap-3">
      <button
        onClick={() => setCount((c) => c - 1)}
        className="rounded bg-slate-200 px-3 py-1"
      >
        -
      </button>
      <span className="text-xl font-semibold">{count}</span>
      <button
        onClick={() => setCount((c) => c + 1)}
        className="rounded bg-slate-200 px-3 py-1"
      >
        +
      </button>
    </div>
  );
}

Puis déposez <Counter /> dans une page. useAtom renvoie le même tuple que useState, mais la valeur est partagée dans toute l'application.

Important — "use client". Les hooks Jotai (useAtom, useSetAtom, useAtomValue) ne fonctionnent que dans les composants client. Ajoutez "use client" en haut de tout fichier de composant qui les utilise.

Variantes lecture seule et écriture seule

Si un composant ne fait que lire, utilisez useAtomValue pour éviter le setter. S'il n'écrit qu'au besoin, utilisez useSetAtom pour éviter de re-rendre quand la valeur change.

import { useAtomValue, useSetAtom } from "jotai";
import { countAtom } from "@/atoms/counter";
 
function Display() {
  const count = useAtomValue(countAtom);
  return <span>{count}</span>;
}
 
function IncrementButton() {
  const setCount = useSetAtom(countAtom);
  return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}

IncrementButton ne re-render jamais quand count change, bien qu'il écrive dans le même atome. C'est automatique — sans aucune memoization.


Étape 3 : Atomes dérivés

Un atome dérivé lit d'autres atomes et se recalcule chaque fois qu'un d'eux change. C'est le cœur de l'état atomique — vous déclarez les relations, et Jotai les maintient synchronisées.

Créez src/atoms/cart.ts :

import { atom } from "jotai";
 
export type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};
 
export const cartItemsAtom = atom<CartItem[]>([]);
 
export const cartTotalAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
 
export const cartCountAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.quantity, 0);
});

cartTotalAtom et cartCountAtom sont des atomes dérivés en lecture seule. Ils dépendent de cartItemsAtom, donc dès que les items changent, les totaux se mettent à jour — et seuls les composants lisant ces atomes dérivés se rafraîchissent.

Atomes dérivés en écriture

Vous pouvez aussi créer des atomes qui transforment les écritures. Le second argument de atom est une fonction d'écriture :

export const addItemAtom = atom(
  null, // pas de valeur de lecture — écriture seule
  (get, set, newItem: CartItem) => {
    const items = get(cartItemsAtom);
    const existing = items.find((i) => i.id === newItem.id);
 
    if (existing) {
      set(
        cartItemsAtom,
        items.map((i) =>
          i.id === newItem.id
            ? { ...i, quantity: i.quantity + newItem.quantity }
            : i,
        ),
      );
    } else {
      set(cartItemsAtom, [...items, newItem]);
    }
  },
);
 
export const removeItemAtom = atom(null, (get, set, id: string) => {
  set(
    cartItemsAtom,
    get(cartItemsAtom).filter((i) => i.id !== id),
  );
});

Les atomes d'action comme ceux-ci gardent vos composants dépourvus de logique métier. Un bouton appelle juste setAddItem(newItem) sans connaître les règles.

"use client";
 
import { useSetAtom } from "jotai";
import { addItemAtom } from "@/atoms/cart";
 
export function AddToCart({ item }: { item: CartItem }) {
  const addItem = useSetAtom(addItemAtom);
  return (
    <button onClick={() => addItem({ ...item, quantity: 1 })}>
      Ajouter au panier
    </button>
  );
}

Étape 4 : État persistant avec atomWithStorage

Les vrais paniers survivent aux rechargements. Jotai livre un utilitaire atomWithStorage qui synchronise un atome avec localStorage, sessionStorage, ou n'importe quel stockage personnalisé :

import { atomWithStorage } from "jotai/utils";
import type { CartItem } from "./cart";
 
export const persistedCartAtom = atomWithStorage<CartItem[]>(
  "noqta-cart-v1",
  [],
);

Remplacez cartItemsAtom par persistedCartAtom là où vous voulez la persistance. L'API est identique — c'est une mise à niveau sans effort.

Prévenir les mismatchs d'hydratation SSR

Sur le serveur, localStorage n'existe pas. atomWithStorage renvoie la valeur initiale côté serveur et hydrate la vraie valeur côté client. Pour éviter le flash de contenu par défaut, attendez le montage :

"use client";
 
import { useEffect, useState } from "react";
import { useAtomValue } from "jotai";
import { persistedCartAtom } from "@/atoms/cart";
 
export function CartBadge() {
  const [mounted, setMounted] = useState(false);
  const items = useAtomValue(persistedCartAtom);
 
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
 
  return <span className="badge">{items.length}</span>;
}

Astuce. Vous pouvez aussi utiliser l'option getOnInit: true pour que atomWithStorage lise le stockage de manière synchrone pendant l'hydratation. Cela évite le flash, mais ne fonctionne en toute sécurité que sur les pages entièrement rendues côté client.


Étape 5 : Atomes asynchrones pour le fetching

Les atomes Jotai peuvent être asynchrones. La fonction de lecture peut retourner une Promise, et Jotai s'intègre à React Suspense pour attendre sa résolution.

import { atom } from "jotai";
 
export type Product = {
  id: string;
  name: string;
  price: number;
};
 
export const productsAtom = atom(async (): Promise<Product[]> => {
  const res = await fetch("/api/products");
  if (!res.ok) throw new Error("Failed to load products");
  return res.json();
});

Lisez-le avec useAtomValue — la valeur est déjà déballée :

"use client";
 
import { Suspense } from "react";
import { useAtomValue } from "jotai";
import { productsAtom } from "@/atoms/products";
 
function ProductList() {
  const products = useAtomValue(productsAtom);
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}
 
export function ProductsPage() {
  return (
    <Suspense fallback={<p>Chargement des produits...</p>}>
      <ProductList />
    </Suspense>
  );
}

Éviter Suspense avec loadable

Si vous préférez des états granulaires de chargement et d'erreur plutôt que Suspense, enveloppez l'atome avec loadable :

import { loadable } from "jotai/utils";
 
export const productsLoadableAtom = loadable(productsAtom);
const value = useAtomValue(productsLoadableAtom);
 
if (value.state === "loading") return <Skeleton />;
if (value.state === "hasError") return <Error error={value.error} />;
return <ProductList data={value.data} />;

loadable est idéal pour les dashboards où vous voulez des loaders indépendants par widget, pas un fallback unique pour toute la page.


Étape 6 : Familles d'atomes pour collections dynamiques

Les familles d'atomes créent des atomes à la demande, indexés par un paramètre. Parfait pour les patterns « un atome par item » — pensez à une todo list où chaque tâche a son propre atome.

import { atomFamily } from "jotai/utils";
import { atom } from "jotai";
 
export type Todo = { id: string; text: string; done: boolean };
 
export const todoAtomFamily = atomFamily((id: string) =>
  atom<Todo>({ id, text: "", done: false }),
);

Utilisez-la dans un composant en appelant la famille avec un id :

function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id));
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={(e) => setTodo({ ...todo, done: e.target.checked })}
      />
      {todo.text}
    </label>
  );
}

Mettre à jour une tâche ne re-render que son TodoItem — les items voisins ne clignotent pas. C'est la granularité atomique en action.


Étape 7 : Hydratation SSR dans Next.js App Router

Next.js 15 rend les pages sur le serveur. Pour amorcer les atomes avec les données serveur, utilisez useHydrateAtoms de jotai/utils. Enveloppez les valeurs initiales dans un composant client qui s'exécute une fois, puis rendez les enfants.

Créez src/components/AtomsHydrator.tsx :

"use client";
 
import { useHydrateAtoms } from "jotai/utils";
import type { ReactNode } from "react";
import type { Product } from "@/atoms/products";
import { productsAtom } from "@/atoms/products";
 
type HydrateProps = {
  products: Product[];
  children: ReactNode;
};
 
export function AtomsHydrator({ products, children }: HydrateProps) {
  useHydrateAtoms([[productsAtom, products]]);
  return <>{children}</>;
}

Récupérez sur le serveur, puis passez les données :

// src/app/page.tsx (server component)
import { AtomsHydrator } from "@/components/AtomsHydrator";
import { ProductList } from "@/components/ProductList";
 
async function getProducts() {
  const res = await fetch("https://api.noqta.tn/products", {
    next: { revalidate: 60 },
  });
  return res.json();
}
 
export default async function Page() {
  const products = await getProducts();
 
  return (
    <AtomsHydrator products={products}>
      <ProductList />
    </AtomsHydrator>
  );
}

Quand le client démarre, productsAtom contient déjà les données serveur — pas de flash de chargement, pas de refetch.

Pourquoi pas un Provider avec initialValues ? Dans Jotai v1, vous pouviez passer initialValues à <Provider>. Dans Jotai 2, l'API est useHydrateAtoms. C'est un hook unique qui fonctionne que vous utilisiez un Provider ou non.

Portée Provider pour l'état multi-tenant

La plupart des applications n'ont pas besoin de <Provider>. La portée par défaut est globale, ce qui est généralement souhaitable. N'utilisez un Provider que si vous avez besoin d'instances d'atomes isolées — par exemple, une modale qui ne doit pas fuir son état, ou des dashboards multi-tenants où différents panneaux contiennent différents utilisateurs.

import { Provider } from "jotai";
 
export function TenantScope({ children }: { children: ReactNode }) {
  return <Provider>{children}</Provider>;
}

Tout ce qui est à l'intérieur de TenantScope lit sa propre copie de chaque atome utilisé à l'intérieur, isolée du reste de l'arbre.


Étape 8 : Déboguer avec Jotai DevTools

L'installation a été faite à l'étape 1. Montez le composant DevTools uniquement en développement :

// src/components/DevTools.tsx
"use client";
 
import { DevTools as JotaiDevTools } from "jotai-devtools";
import "jotai-devtools/styles.css";
 
export function DevTools() {
  if (process.env.NODE_ENV !== "development") return null;
  return <JotaiDevTools theme="dark" />;
}

Rendez-le en haut de votre layout racine. Vous obtenez un panneau montrant chaque atome vivant, sa valeur, ses dépendances, et un curseur d'historique de time-travel.


Étape 9 : Tout mettre ensemble — le panier d'achat

Créez src/atoms/shop.ts :

import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
 
export type Product = { id: string; name: string; price: number };
export type CartLine = { productId: string; quantity: number };
 
export const productsAtom = atom<Product[]>([]);
 
export const cartLinesAtom = atomWithStorage<CartLine[]>("noqta-cart", []);
 
export const cartDetailsAtom = atom((get) => {
  const products = get(productsAtom);
  const lines = get(cartLinesAtom);
 
  return lines
    .map((line) => {
      const product = products.find((p) => p.id === line.productId);
      if (!product) return null;
      return {
        ...product,
        quantity: line.quantity,
        subtotal: product.price * line.quantity,
      };
    })
    .filter(Boolean);
});
 
export const cartTotalAtom = atom((get) =>
  get(cartDetailsAtom).reduce((sum, l) => sum + (l?.subtotal ?? 0), 0),
);
 
export const addToCartAtom = atom(
  null,
  (get, set, productId: string, quantity = 1) => {
    const lines = get(cartLinesAtom);
    const existing = lines.find((l) => l.productId === productId);
    if (existing) {
      set(
        cartLinesAtom,
        lines.map((l) =>
          l.productId === productId
            ? { ...l, quantity: l.quantity + quantity }
            : l,
        ),
      );
    } else {
      set(cartLinesAtom, [...lines, { productId, quantity }]);
    }
  },
);
 
export const removeFromCartAtom = atom(null, (get, set, productId: string) => {
  set(
    cartLinesAtom,
    get(cartLinesAtom).filter((l) => l.productId !== productId),
  );
});

Et le composant de résumé du panier :

"use client";
 
import { useAtomValue, useSetAtom } from "jotai";
import {
  cartDetailsAtom,
  cartTotalAtom,
  removeFromCartAtom,
} from "@/atoms/shop";
 
export function CartSummary() {
  const items = useAtomValue(cartDetailsAtom);
  const total = useAtomValue(cartTotalAtom);
  const remove = useSetAtom(removeFromCartAtom);
 
  if (items.length === 0) return <p>Votre panier est vide.</p>;
 
  return (
    <div className="space-y-3">
      {items.map((item) =>
        item ? (
          <div
            key={item.id}
            className="flex items-center justify-between border-b py-2"
          >
            <div>
              <p className="font-medium">{item.name}</p>
              <p className="text-sm text-slate-500">
                {item.quantity} x ${item.price}
              </p>
            </div>
            <div className="flex items-center gap-3">
              <span className="font-semibold">${item.subtotal}</span>
              <button
                onClick={() => remove(item.id)}
                className="text-red-500 hover:underline"
              >
                Retirer
              </button>
            </div>
          </div>
        ) : null,
      )}
      <div className="flex justify-between pt-2 text-lg font-bold">
        <span>Total</span>
        <span>${total}</span>
      </div>
    </div>
  );
}

Trois atomes, deux actions, zéro boilerplate Redux. Chaque composant ne s'abonne qu'aux atomes qu'il lit — ajoutez une ligne et seul le résumé du panier se rafraîchit, pas la grille des produits.


Tester votre implémentation

Les atomes Jotai sont facilement testables car ce sont des valeurs simples. Utilisez l'aide createStore pour obtenir un store isolé par test :

import { createStore } from "jotai";
import { cartLinesAtom, addToCartAtom, cartTotalAtom } from "@/atoms/shop";
 
test("adds items and computes total", () => {
  const store = createStore();
  store.set(addToCartAtom, "p1", 2);
  store.set(addToCartAtom, "p1", 1);
 
  expect(store.get(cartLinesAtom)).toEqual([
    { productId: "p1", quantity: 3 },
  ]);
});

Couplez cela avec Vitest ou Jest et vous couvrirez chaque interaction d'atome sans monter React.


Dépannage

Les composants se rafraîchissent plus que prévu. Vous déstructurez probablement un atome objet. Divisez les gros atomes en plus petits, ou utilisez selectAtom pour vous abonner à une tranche.

Mismatch d'hydratation avec atomWithStorage. Gérez un drapeau mounted dans les composants client, ou ne rendez l'atome adossé au stockage qu'après le premier montage.

"Cannot find module 'jotai/utils'". TypeScript a parfois besoin d'une map de chemins explicite. Assurez-vous que votre tsconfig.json possède "moduleResolution": "bundler" (Next.js 15 le définit par défaut).

Un atome async lance une erreur pendant le SSR. Enveloppez le consommateur dans <Suspense>, ou passez à loadable pour rendre l'état inspectable au lieu de suspendu.

Le panneau DevTools n'apparaît pas. Confirmez que vous avez importé jotai-devtools/styles.css et que process.env.NODE_ENV vaut "development" à l'exécution.


Étapes suivantes


Conclusion

Jotai est trompeusement simple : une primitive atom unique qui se compose en architectures d'application complètes. Le modèle atomique vous donne une réactivité fine automatique, presque zéro boilerplate et une expérience TypeScript délicieuse. Quand votre application React dépasse useState et que vous voulez une bibliothèque d'état qui se sent comme du React natif, Jotai est l'option la plus ergonomique en 2026.

Vous disposez maintenant de la boîte à outils complète : atomes primitifs, atomes dérivés, actions en écriture, persistance, données asynchrones, hydratation SSR, familles d'atomes et devtools. Livrez quelque chose avec.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Demarrage Rapide avec Gemma sur KerasNLP.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes