Aller au contenu

À distanceHeures locales: Kigali, Nairobi, Dubaï

6 min de lectureReact

Migrer de Recoil à Jotai, un store à la fois

Comment les atomes, sélecteurs et familles de Recoil se traduisent en Jotai, les deux endroits où cette correspondance cache un changement de comportement, et une façon de migrer une grosse application React sans geler le travail sur les fonctionnalités.

Recoil présentait l'état partagé comme quelque chose qui pouvait ressembler à useState. On déclare un atome, on le lit avec un hook, et chaque composant qui le lit se rend à nouveau quand il change. Jotai fait la même promesse avec une API plus petite et sans clés en chaîne de caractères. Les deux sont assez proches pour que l'essentiel d'une migration soit mécanique, et c'est précisément pour cela que les parties qui ne le sont pas passent inaperçues.

J'ai ouvert la pull request qui a fait passer l'application web d'Ever Teams de Recoil à Jotai, #3037, en septembre 2024. Huit mois plus tard, #3855 a organisé les stores Jotai obtenus par domaine, sous core/stores. Cet article couvre la correspondance entre les deux API, les endroits où elle cache un changement de comportement, et une façon de migrer store par store quand une seule grosse pull request n'est pas envisageable. Les exemples utilisent un domaine générique de tâches et ont été écrits pour l'article. Aucun ne vient d'Ever Teams.

Ce qui est vérifié et ce qui est général

Les numéros, titres, dates et nombres de fichiers des pull requests viennent de l'historique public de github.com/ever-co/ever-teams, consulté le 2026-10-05. La méthode store par store décrite plus bas est une technique générale. Elle ne décrit pas la migration d'Ever Teams : l'historique public montre ce changement de bibliothèque en une seule pull request.

Pourquoi quitter Recoil

La dernière version de Recoil, la 0.7.7, date d'avril 2023, et son dépôt sous facebookexperimental a depuis été archivé. Une bibliothèque d'état qui ne publie plus tient encore un moment, puis casse lors d'une montée de version de React ou de Next.js, à un endroit qu'on ne peut pas corriger sans la forker.

Jotai garde le modèle d'atomes et abandonne le registre global. Un atome est un simple objet de configuration. Sa valeur vit dans un store, et son identité vient de la référence de l'objet au lieu d'une chaîne key qui doit être unique dans toute l'application. Un Provider limite un store à un sous-arbre ; sans lui, Jotai se rabat sur un store par défaut.

Correspondance des API

L'essentiel du travail est un renommage.

RecoilJotai
atom({ key, default })atom(initialValue)
selector({ key, get })atom((get) => value)
selector modifiable avec setatom(read, write)
atomFamily, selectorFamilyatomFamily de jotai-family (de jotai/utils jusqu'à Jotai 2)
useRecoilStateuseAtom
useRecoilValueuseAtomValue
useSetRecoilStateuseSetAtom
useRecoilCallbackuseAtomCallback, ou useStore() avec store.get et store.set
effects d'atome pour la persistanceatomWithStorage, ou onMount sur l'atome
RecoilRootProvider

Voici une valeur dérivée avant et après.

tsIllustration
// Avant : Recoil
import { atom, selector } from 'recoil';
 
type Task = { id: string; title: string; status: 'open' | 'done' };
 
export const tasksState = atom<Task[]>({ key: 'tasksState', default: [] });
export const showDoneState = atom<boolean>({ key: 'showDoneState', default: false });
 
export const visibleTasksState = selector({
  key: 'visibleTasksState',
  get: ({ get }) => {
    const tasks = get(tasksState);
    return get(showDoneState) ? tasks : tasks.filter((task) => task.status === 'open');
  },
});
tsIllustration
// Après : Jotai
import { atom } from 'jotai';
 
type Task = { id: string; title: string; status: 'open' | 'done' };
 
export const tasksAtom = atom<Task[]>([]);
export const showDoneAtom = atom(false);
 
export const visibleTasksAtom = atom((get) => {
  const tasks = get(tasksAtom);
  return get(showDoneAtom) ? tasks : tasks.filter((task) => task.status === 'open');
});

Les clés disparaissent, et avec elles l'erreur d'exécution de deux atomes qui partagent une clé après un copier-coller. Renseignez debugLabel sur les atomes que vous voulez retrouver dans les devtools de Jotai.

Là où la correspondance cache un changement

Les familles comparent leurs paramètres autrement

Recoil sérialise les paramètres des familles, donc taskFamily({ teamId: 'a' }) appelé deux fois renvoie le même atome alors que les deux objets sont différents. L'atomFamily de Jotai compare les paramètres avec Object.is par défaut. Passez un objet littéral depuis un composant et chaque rendu crée un nouvel atome : l'état repart de zéro à chaque rendu, et la famille garde tous les atomes qu'elle a jamais créés.

tsIllustration
import { atom } from 'jotai';
import { atomFamily } from 'jotai-family';
 
type Task = { id: string; title: string; status: 'open' | 'done' };
type TaskFilter = { teamId: string; status: Task['status'] };
 
// Paramètre primitif : l'égalité par valeur est gratuite.
export const teamTasksAtom = atomFamily((teamId: string) => {
  const tasks = atom<Task[]>([]);
  tasks.debugLabel = `tasks:${teamId}`;
  return tasks;
});
 
// Paramètre objet : dire quand deux paramètres sont identiques.
export const filteredTasksAtom = atomFamily(
  (filter: TaskFilter) =>
    atom((get) => get(teamTasksAtom(filter.teamId)).filter((task) => task.status === filter.status)),
  (a, b) => a.teamId === b.teamId && a.status === b.status,
);

Le nettoyage vous revient aussi. teamTasksAtom.remove(teamId) supprime l'atome d'une équipe qui n'existe plus, et setShouldRemove permet d'évincer les atomes selon leur âge.

Le store par défaut est partagé côté serveur

Sans Provider, Jotai utilise un store par défaut créé au niveau du module. Dans une application purement client, aucun souci. Dans Next.js, le même module sert toutes les requêtes côté serveur, donc une valeur écrite dans ce store pendant le rendu serveur, avec useHydrateAtoms par exemple, peut se retrouver dans le HTML d'un autre utilisateur. Un Provider dans un composant client à la racine donne à chaque rendu son propre store.

tsxIllustration
// app/state-provider.tsx
'use client';
import { Provider } from 'jotai';
import type { ReactNode } from 'react';
 
export function StateProvider({ children }: { children: ReactNode }) {
  return <Provider>{children}</Provider>;
}

Migrer store par store

Les deux bibliothèques peuvent cohabiter dans la même application. RecoilRoot reste où il est, le Provider de Jotai se place à côté, et l'application continue d'être livrée pendant que l'état migre par morceaux. L'ordre compte plus que la vitesse.

D'abord, placez chaque consommateur derrière un hook de domaine. Les composants n'importent plus d'atomes et appellent useVisibleTasks() ou useShowDone() à la place. Cette étape ne change aucun comportement, et elle réduit chaque étape suivante aux fichiers d'un seul store.

tsIllustration
// features/tasks/state.ts
import { useAtom, useAtomValue } from 'jotai';
import { showDoneAtom, visibleTasksAtom } from './atoms';
 
// Avant la migration de ce store, ces deux hooks enveloppaient useRecoilState et useRecoilValue.
export const useShowDone = () => useAtom(showDoneAtom);
export const useVisibleTasks = () => useAtomValue(visibleTasksAtom);

Ensuite, migrez un store à la fois, en commençant par les feuilles : les atomes qu'aucun sélecteur Recoil ne lit. Chaque déplacement est une petite pull request qui touche un fichier de store et son fichier de hooks, rien d'autre.

Tôt ou tard, un sélecteur Recoil pas encore migré a besoin d'un atome qui l'est déjà. La solution est un pont temporaire qui copie la valeur Jotai dans un atome Recoil, dans un seul sens, et qui disparaît avec le dernier sélecteur qui la lit.

tsxIllustration
'use client';
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { useSetRecoilState } from 'recoil';
import { showDoneAtom } from '@/features/tasks/atoms';
import { legacyShowDoneState } from '@/legacy/tasks';
 
// Temporaire. À supprimer avec le dernier sélecteur Recoil qui lit legacyShowDoneState.
export function ShowDoneBridge() {
  const showDone = useAtomValue(showDoneAtom);
  const setLegacy = useSetRecoilState(legacyShowDoneState);
  useEffect(() => {
    setLegacy(showDone);
  }, [showDone, setLegacy]);
  return null;
}

Un pont coûte un rendu de retard, puisque l'effet s'exécute après le commit : gardez-en peu et pour peu de temps. Pendant la migration, une règle no-restricted-imports sur recoil hors du dossier legacy empêche le nouveau code d'alourdir la pile. La dernière pull request retire RecoilRoot, les ponts et la dépendance.

Ce que montre l'historique d'Ever Teams

Sur Ever Teams, le changement a pris l'autre chemin. #3037, intitulée « Refactor/recoil to jotai », a été ouverte le 18 septembre 2024, fusionnée le 20 septembre, et a modifié 168 fichiers. Une seule pull request supprime toute période de cohabitation : pas de ponts, pas de second provider, et une grosse revue au lieu de plusieurs petites. Une recherche de « recoil » dans les pull requests du dépôt ne renvoie aucune autre pull request de migration, ce qui n'exclut pas un travail de suite sous d'autres titres.

La deuxième étape est arrivée en mai 2025. #3855, « [Web]-Refactor: Organize Jotai Stores by Domain in core/stores », a été fusionnée le 22 mai 2025 et a modifié 118 fichiers, dans le cadre de la réorganisation d'apps/web en une couche core. En juin 2025, les données serveur ont commencé à passer à React Query, une tranche par pull request.

Quel que soit le chemin choisi

Quelle que soit la forme du changement, commencez par les hooks de domaine. Avec les consommateurs derrière des hooks, une migration en une seule PR touche les fichiers de stores et de hooks au lieu de chaque composant, et une migration store par store devient tout simplement possible. Regroupez aussi les stores par domaine pendant le changement, puisque chaque fichier de store est réécrit de toute façon, plutôt que de les déplacer une seconde fois plus tard.

La règle d'égalité d'atomFamily va dans la liste de vérification de revue. Rien ne casse bruyamment quand elle est fausse. L'écran oublie simplement son état, et le rapport de bug dit « le filtre se réinitialise parfois ».