J'ai reviewé des centaines de pull requests React dans plusieurs équipes. Les mêmes erreurs reviennent sans cesse : useMemo autour d'une concaténation de strings, key={index} sur une liste filtrable, des chaînes de useEffect qui pourraient être une simple valeur dérivée. Ce ne sont pas des erreurs de juniors — les seniors les font aussi, parce que le modèle mental de React est trompeusement simple en surface.
Cet article couvre les mécanismes internes qui comptent au quotidien : comment le Fiber tree fonctionne, ce que la reconciliation fait réellement, et les patterns de hooks que la plupart des développeurs utilisent mal. Pas de théorie pour elle-même — chaque section est une décision que vous prendrez dans votre prochain PR.
Le Fiber Tree : ce que React construit réellement
Quand vous écrivez du JSX, vous n'écrivez pas du HTML. Vous décrivez un arbre d'objets JavaScript. React prend cette description et construit un Fiber tree — une liste chaînée d'« unités de travail » où chaque noeud représente un élément ou composant de votre UI.
Chaque noeud Fiber contient des pointeurs vers son enfant, son frère et son parent. Il contient les props du composant, l'état de ses hooks (en tant que liste chaînée — c'est pourquoi l'ordre des hooks est important) et une référence au noeud DOM réel qu'il représente. Point crucial : React maintient deux Fiber trees en permanence : l'arbre « courant » affiché à l'écran, et un arbre « work-in-progress » construit pendant un render.
Pourquoi c'est important ? Parce que quand React « re-rend », il ne touche pas encore le DOM. Il construit un nouveau Fiber tree work-in-progress, le compare à l'arbre courant, calcule l'ensemble minimal de changements DOM nécessaires, puis les applique en un seul batch. C'est la reconciliation.
// React doesn't work with the DOM directly.
// It maintains a Fiber tree — a linked list of "work units"
// Each Fiber node looks roughly like this:
interface FiberNode {
type: 'div' | 'span' | typeof MyComponent; // what to render
key: string | null;
props: Record<string, unknown>;
stateNode: HTMLElement | null; // the actual DOM node (or component instance)
child: FiberNode | null; // first child
sibling: FiberNode | null; // next sibling
return: FiberNode | null; // parent
alternate: FiberNode | null; // the "other" tree (current vs work-in-progress)
effectTag: 'PLACEMENT' | 'UPDATE' | 'DELETION';
memoizedState: unknown; // hooks live here, as a linked list
}
// When you write JSX, React builds this tree — NOT a DOM tree.
// <App> Fiber: App
// <Header /> Fiber: Header (child of App)
// <main> Fiber: main (sibling of Header)
// <Article /> Fiber: Article (child of main)
// </main>
// </App>
// Each Fiber holds its own hooks state (memoizedState),
// a pointer to its DOM node, and links to parent/child/sibling.Reconciliation : comment React décide de ce qui change
La reconciliation est l'algorithme de diffing de React. Il parcourt les deux Fiber trees (courant et work-in-progress) et applique trois règles pour décider quoi faire de chaque noeud.
Règle 1 : si le type d'élément a changé (div en span, ComponentA en ComponentB), React détruit tout le sous-arbre et le reconstruit de zéro. Tout l'état est perdu. Tous les enfants sont unmountés et remountés. C'est pourquoi wrapper un composant dans un conditionnel qui alterne entre deux types de composants différents est coûteux — ce n'est pas une mise à jour, c'est une destruction et reconstruction complète.
Règle 2 : si le type d'élément est le même, React garde l'instance en vie et met à jour uniquement les props modifiées. C'est le chemin rapide. Un bouton changeant de className="blue" à className="red" est une seule mise à jour d'attribut DOM.
Règle 3 : pour les listes d'enfants, React utilise les keys pour faire correspondre les éléments entre l'ancien et le nouveau arbre. Sans keys, il fait correspondre par index — ce qui signifie que l'ajout en début de liste fait « mettre à jour » chaque élément existant avec les mauvaises données. Avec des keys stables, React identifie quels éléments ont bougé, lesquels ont été ajoutés et supprimés, et applique l'ensemble minimal d'opérations.
// Reconciliation = React's diffing algorithm
// It compares the OLD Fiber tree with the NEW one, node by node.
// Rule 1: Different type? Tear down and rebuild.
// Old: <div><Counter /></div>
// New: <span><Counter /></span>
// Result: entire <div> subtree destroyed, <span> subtree created from scratch.
// Counter loses ALL state — even if the component is identical.
// Rule 2: Same type? Update props, keep the instance.
// Old: <button className="blue" onClick={handleA}>Save</button>
// New: <button className="red" onClick={handleB}>Save</button>
// Result: React updates className and onClick on the existing DOM node.
// No unmount, no remount. Fast.
// Rule 3: Lists need keys for identity.
// Without keys, React matches by index:
// Old: [Alice, Bob, Charlie]
// New: [Zara, Alice, Bob, Charlie] // prepended
// React thinks: Alice→Zara (update), Bob→Alice (update), Charlie→Bob (update), +Charlie (insert)
// That's 4 operations instead of 1 insert.
// With keys:
// Old: [Alice(1), Bob(2), Charlie(3)]
// New: [Zara(0), Alice(1), Bob(2), Charlie(3)]
// React matches by key: Alice, Bob, Charlie are unchanged. Just insert Zara.
// 1 operation. That's why keys exist.Les Keys : bien plus qu'un « identifiant unique »
Tout le monde sait utiliser key={item.id} sur les listes. Mais les keys ont un rôle plus profond que la plupart des développeurs ne voient pas : elles contrôlent l'identité du composant dans le Fiber tree.
Quand une key change, React traite le composant comme complètement différent — même si le type est le même. L'ancienne instance est unmountée (tout l'état réinitialisé), et une nouvelle est mountée. C'est incroyablement utile pour réinitialiser l'état d'un composant sans hacks de useEffect.
L'exemple classique : un formulaire qui doit se réinitialiser quand l'utilisateur passe à un autre enregistrement. Au lieu d'un useEffect qui surveille l'ID et réinitialise manuellement l'état (fragile et crée des états intermédiaires), keyez simplement le formulaire sur l'ID de l'enregistrement. Quand l'ID change, React fait un unmount/remount propre. Tous les appels useState retournent leurs valeurs initiales. Tous les cleanups useEffect s'exécutent. Un nouveau départ complet, sans aucun code dans le composant.
// DON'T: key={index} on a list that reorders, filters, or prepends
// This destroys the performance benefit AND causes state bugs
{items.map((item, i) => (
<TodoItem key={i} item={item} /> // index shifts when items move
))}
// DO: key={item.id} — a stable identity that follows the data
{items.map((item) => (
<TodoItem key={item.id} item={item} />
))}
// TRICK: Use key to FORCE a full remount when you want to reset state
// This is an intentional, legitimate use of reconciliation:
function EditForm({ userId }: { userId: string }) {
// Changing userId doesn't reset internal form state...
const [name, setName] = useState('');
// ...because React sees the same component type at the same position
}
// Fix: key the component on the identity that should trigger a reset
<EditForm key={userId} userId={userId} />
// Now when userId changes, React unmounts the old EditForm
// and mounts a fresh one — all state resets automatically.
// No useEffect cleanup, no stale state bugs.useMemo : quand ça aide vraiment (et quand c'est du gaspillage)
useMemo est le hook le plus surutilisé en React. J'ai vu des codebases où chaque valeur calculée est wrappée dans useMemo « pour la performance ». C'est contre-productif — useMemo a un coût. Il stocke le résultat précédent et les dépendances, compare les dépendances à chaque render et retourne la valeur en cache si rien n'a changé. Pour les calculs bon marché, la comparaison coûte plus que le recalcul.
La règle est simple : useMemo est pour les calculs coûteux (trier de grands tableaux, transformations de données complexes, parsing) ou pour stabiliser les références passées à des enfants React.memo. Cette deuxième partie est critique — useMemo sans React.memo sur le composant récepteur est presque toujours inutile, parce que l'enfant re-rend de toute façon quand son parent re-rend.
Pensez à useMemo + React.memo comme un duo. useMemo stabilise la référence. React.memo sur l'enfant évite le re-render quand les props n'ont pas changé. Retirez l'un des deux et l'optimisation ne fonctionne pas.
// DON'T: useMemo everything "just in case"
// This is SLOWER than no memo for cheap operations:
const fullName = useMemo(() => first + ' ' + last, [first, last]);
// The comparison of [first, last] costs more than the concatenation.
// DON'T: useMemo on object literals passed to children
// unless the child is wrapped in React.memo
const style = useMemo(() => ({ color: 'red' }), []);
// If <Child> isn't React.memo'd, it re-renders anyway when its parent does.
// The stable reference achieves nothing.
// DO: useMemo for genuinely expensive computations
const sortedItems = useMemo(
() => items.toSorted((a, b) => a.score - b.score),
[items]
);
// Sorting 10,000 items on every render is real waste.
// DO: useMemo + React.memo as a pair
const MemoizedChild = React.memo(ExpensiveChart);
function Dashboard({ data }: { data: DataPoint[] }) {
const processed = useMemo(() => transformData(data), [data]);
// useMemo keeps the reference stable
// React.memo on the child skips re-render if props are the same
return <MemoizedChild data={processed} />;
// BOTH pieces are needed. useMemo alone does nothing visible.
// React.memo alone doesn't help if the parent creates new objects each render.
}useCallback : les mêmes règles s'appliquent
useCallback est littéralement useMemo(() => fn, deps) sous le capot. Les mêmes règles s'appliquent : c'est utile uniquement quand la référence stable empêche du travail en aval — ce qui signifie que le récepteur doit être wrappé dans React.memo, ou que la fonction est utilisée dans un tableau de dépendances useEffect.
J'impose une règle d'équipe simple : pas de useCallback sauf si vous pouvez montrer le React.memo ou le useEffect qui en bénéficie. Si vous ne pouvez pas, vous ajoutez de la complexité pour zéro gain de performance.
// useCallback has the SAME rules as useMemo — it's literally
// useMemo(() => fn, deps) under the hood.
// DON'T: wrap every handler in useCallback
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
// Unless <Child> is React.memo, this is pure overhead.
// DO: useCallback when passing to a memoized child
const MemoButton = React.memo(Button);
function Toolbar({ onSave }: { onSave: () => void }) {
// If the parent re-renders, a new arrow function is created each time
// React.memo on Button would see a new prop and re-render anyway
// useCallback keeps the same function reference across renders
const handleSave = useCallback(() => onSave(), [onSave]);
return <MemoButton onClick={handleSave}>Save</MemoButton>;
}
// DO: useCallback for functions used in useEffect dependencies
function useAutoSave(data: Data) {
const save = useCallback(() => {
api.save(data);
}, [data]);
useEffect(() => {
const id = setInterval(save, 5000);
return () => clearInterval(id);
}, [save]); // stable reference prevents re-creating the interval
}Initialisation paresseuse de l'état : le fix en une ligne
useState accepte soit une valeur soit une fonction. Quand vous passez une fonction, React ne l'appelle qu'au montage initial — pas à chaque render. C'est important quand la valeur initiale est coûteuse à calculer : lecture du localStorage, parsing JSON, création d'instances de classes.
Je vois cette erreur constamment : useState(JSON.parse(localStorage.getItem(key)!)). Ce parse s'exécute à chaque render, mais seul le premier résultat est utilisé. Le fix c'est un caractère : useState(() => JSON.parse(localStorage.getItem(key)!)). L'arrow function le rend paresseux.
// DON'T: expensive computation runs on EVERY render
const [state, setState] = useState(parseJsonFromStorage(key));
// parseJsonFromStorage runs every render, but only the first result is used.
// DO: pass a function — React calls it only on mount
const [state, setState] = useState(() => parseJsonFromStorage(key));
// The () => wrapper makes it lazy. Runs once.
// Same pattern with useRef — but useRef has no lazy initializer:
// DON'T:
const worker = useRef(new HeavyWorker()); // created every render, discarded
// DO:
const workerRef = useRef<HeavyWorker | null>(null);
function getWorker() {
if (!workerRef.current) workerRef.current = new HeavyWorker();
return workerRef.current;
}
// Lazy singleton pattern. Created on first access, not on every render.useRef au-delà du DOM
useRef est sous-utilisé. La plupart des développeurs pensent qu'il sert uniquement à accéder aux éléments DOM. Mais sa vraie puissance est comme conteneur mutable qui persiste entre les renders sans en déclencher. Toute valeur qui doit survivre aux renders mais ne devrait pas causer de re-render quand elle change appartient à un ref.
Les patterns ci-dessous sont ceux que j'utilise le plus : tracker les valeurs précédentes, créer des callbacks stables qui appellent toujours la dernière version de la fonction (résout le problème des closures périmées), et construire des intervals/timeouts qui n'ont pas besoin d'être recréés quand leur callback change.
// useRef is NOT just for DOM elements.
// It's a mutable container that survives re-renders without causing them.
// Track previous value (no re-render, no useEffect)
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>();
useEffect(() => { ref.current = value; });
return ref.current;
}
// Stable callback that always calls the latest version
// without re-triggering effects that depend on it
function useStableCallback<T extends (...args: never[]) => unknown>(fn: T): T {
const ref = useRef(fn);
ref.current = fn; // always up to date, no dependency array needed
return useCallback(
((...args) => ref.current(...args)) as T,
[] // genuinely stable — never changes
);
}
// Avoid stale closures in intervals/timeouts
function useInterval(callback: () => void, ms: number) {
const callbackRef = useRef(callback);
callbackRef.current = callback; // always fresh
useEffect(() => {
const id = setInterval(() => callbackRef.current(), ms);
return () => clearInterval(id);
}, [ms]); // only re-creates when interval changes, not when callback does
}Les règles qui comptent vraiment
Après des années à diriger des équipes React, voici les règles que j'applique. Pas de useMemo ni useCallback sauf si vous pouvez nommer le React.memo ou useEffect qui en bénéficie. Utilisez key pour contrôler l'identité — réinitialisez l'état en changeant les keys, pas avec useEffect. Jamais d'index comme key sur les listes dynamiques. Passez des fonctions à useState pour les valeurs initiales coûteuses. Mettez les valeurs non-rendues dans des refs, pas dans l'état. Dérivez les valeurs pendant le render au lieu de les synchroniser avec useEffect.
La plupart des problèmes de performance React ne se résolvent pas en mémoïsant plus — mais en rendant moins. Déplacez l'état là où il est utilisé. Séparez les composants pour qu'un changement d'état dans le header ne re-rende pas toute la page. Utilisez les props children pour que les composants wrapper ne re-rendent pas leur contenu. Ces changements structurels battent n'importe quelle quantité de useMemo.

