useMemo et useCallback dans React : quand vraiment

1. Vue d’ensemble

useMemo et useCallback sont souvent utilisés au feeling : soit on enveloppe tout « pour être sûr », soit on ne les utilise jamais. Les deux approches sont mauvaises. Cet article explique le mécanisme derrière ces deux hooks, identifie trois situations où ils sont vraiment utiles et les cas à ignorer. Vous écrirez aussi un test pour prouver que la mémoïsation fonctionne réellement.

2. Le problème au cœur : chaque rendu crée tout à nouveau

Chaque fois qu’un composant est re-rendu, tout le corps de la fonction réexécute de zéro. Chaque objet, tableau et fonction déclarés dedans sont recréés.

function Checkout({ customer }) {
  const shipping = { city: customer.city, express: true };
  const handlePay = () => console.log('Paiement pour', customer.name);

  return <PaymentForm shipping={shipping} onPay={handlePay} />;
}

À chaque rendu de Checkout, shipping et handlePay sont de nouvelles références, même si le contenu ne change pas. JavaScript compare les objets et les fonctions par référence :

{ city: 'Huế' } === { city: 'Huế' }   // false
(() => 1) === (() => 1)               // false

Normalement, c’est inoffensif, car créer un objet coûte très peu. Cela ne devient un problème que quand quelque chose compare par référence. Cela peut être React.memo, le tableau de dépendances d’un useEffect, ou un autre hook. useMemo et useCallback existent pour maintenir une référence stable entre les rendus.

3. useMemo : mémoriser une valeur

useMemo(calculate, deps) appelle calculate au premier rendu et sauvegarde le résultat. Aux rendus suivants, React le recalcule seulement si un élément dans deps change, en utilisant Object.is.

3.1. Éviter les calculs coûteux à chaque rendu

import { useMemo, useState } from 'react';

function OrderReport({ orders }) {
  const [keyword, setKeyword] = useState('');
  const [darkMode, setDarkMode] = useState(false);

  const visibleOrders = useMemo(() => {
    const kw = keyword.toLowerCase();
    return orders
      .filter((o) => o.customer.toLowerCase().includes(kw))
      .sort((a, b) => b.total - a.total);
  }, [orders, keyword]);

  return (
    <div className={darkMode ? 'dark' : ''}>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setDarkMode((d) => !d)}>Changer le thème</button>
      <ul>
        {visibleOrders.map((o) => (
          <li key={o.id}>{o.customer} : {o.total}</li>
        ))}
      </ul>
    </div>
  );
}

Explication :

  • Le filter et le sort ne réexécutent que quand orders ou keyword change.
  • Quand vous cliquez sur « Changer le thème », darkMode change et le composant se re-rend. À ce moment, useMemo retourne le tableau déjà mémorisé, sans faire de filtrage à nouveau.
  • Le sort est sans danger ici car filter a déjà retourné un nouveau tableau. Aucune opération ne modifie directement la prop orders.

Comment savez-vous si un calcul est « coûteux » ? Mesurez au lieu de deviner :

console.time('filtrer les commandes');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('filtrer les commandes');
filtrer les commandes : 0.08 ms

Un repère pratique : si le calcul prend moins d’environ 1 ms sur votre machine de dev, il n’a presque pas besoin de mémoïsation. Pensez à mesurer sur une build production, et mieux encore, activez le CPU throttling dans DevTools, car les machines des utilisateurs sont généralement plus lentes que la vôtre.

3.2. Garder un objet stable à transmettre

Le deuxième cas n’est pas lié à la vitesse de calcul. Ici, vous avez besoin d’un objet avec une référence stable pour servir de dépendance :

function CustomerOrders({ customerId, status }) {
  const [orders, setOrders] = useState([]);

  const query = useMemo(() => ({ customerId, status }), [customerId, status]);

  useEffect(() => {
    fetchOrders(query).then(setOrders);
  }, [query]);

  return <OrderTable orders={orders} />;
}

Si vous omettez useMemo, query serait un nouvel objet à chaque rendu. Alors l’effet se réexécuterait continuellement, et comme setOrders déclenche un rendu, vous pourriez entrer dans une boucle d’appels API infinis.

Remarque : Dans cet exemple, il y a une approche plus simple. Vous créez l’objet à l’intérieur de l’effet et placez les valeurs primitives dans la dépendance : useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);. Préférez cette approche. Utilisez useMemo seulement si l’objet doit absolument vivre en dehors de l’effet, par exemple quand il est transmis à un composant enfant.

4. useCallback : mémoriser une fonction

useCallback(fn, deps) est équivalent à useMemo(() => fn, deps). Elle retourne la fonction que vous avez passée et maintient sa référence jusqu’à ce que deps change. useCallback ne rend pas la fonction plus rapide. Ce hook n’a de sens que si la fonction est comparée quelque part.

4.1. Combiner avec React.memo

React.memo ignore le rendu d’un composant enfant si les props sont identiques à la fois précédente (comparaison superficielle). Si une prop est une fonction recréée à chaque fois, memo perd son effet.

import { memo, useCallback, useState } from 'react';

const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
  onRender?.(order.id);
  return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});

export function OrderList({ orders, onRender }) {
  const [selectedId, setSelectedId] = useState(null);
  const [note, setNote] = useState('');

  const handleSelect = useCallback((id) => setSelectedId(id), []);

  return (
    <>
      <input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
      <ul>
        {orders.map((o) => (
          <OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
        ))}
      </ul>
      <p>En cours de sélection : {selectedId ?? 'aucune'}</p>
    </>
  );
}

Explication :

  • handleSelect a une dépendance vide car setSelectedId est toujours stable (React le garantit pour les setters de useState).
  • Quand vous tapez dans la note, OrderList se re-rend. Cependant, chaque OrderRow reçoit le même order, le même handleSelect, le même onRender, donc memo les ignore.
  • Si vous remplacez handleSelect par une fonction fléchée ordinaire, chaque ligne se re-rendrait à chaque frappe.

Point à retenir : useCallback sans memo du côté récepteur est à peu près inutile. Passer un callback mémorisé à une balise <button> du DOM ne vous économise rien.

4.2. Fonction utilisée comme dépendance

function useOrderPolling(customerId) {
  const [orders, setOrders] = useState([]);

  const refresh = useCallback(async () => {
    const data = await fetchOrders({ customerId });
    setOrders(data);
  }, [customerId]);

  useEffect(() => {
    refresh();
    const id = setInterval(refresh, 10_000);
    return () => clearInterval(id);
  }, [refresh]);

  return { orders, refresh };
}

refresh est à la fois utilisée dans l’effet et retournée au composant externe. Grâce à useCallback, l’intervalle n’est recréé que quand customerId change. Aucun composant utilisant refresh comme dépendance ne sera re-rendu inutilement.

C’est une règle à suivre quand vous écrivez des custom hooks : les fonctions retournées doivent être enveloppées avec useCallback. Vous ne pouvez pas savoir à l’avance si le consommateur du hook les placera dans une dépendance ou les passera à memo.

5. Vérifier avec des tests

Au lieu de supposer que la mémoïsation « fonctionne certainement », vous pouvez écrire un test pour cela. Installez Vitest et React Testing Library :

npm install -D vitest jsdom @testing-library/react

Configurez l’environnement jsdom dans vite.config.js :

export default {
  test: { environment: 'jsdom' },
};

Test pour OrderList de la section 4.1 :

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';

const orders = [
  { id: 1, customer: 'Lan' },
  { id: 2, customer: 'Minh' },
];

describe('OrderList', () => {
  it('ne re-rend pas OrderRow en tapant juste une note', () => {
    const onRender = vi.fn();
    render(<OrderList orders={orders} onRender={onRender} />);
    expect(onRender).toHaveBeenCalledTimes(2);

    fireEvent.change(screen.getByLabelText('note'), { target: { value: 'livraison rapide' } });

    expect(onRender).toHaveBeenCalledTimes(2);
  });

  it('met à jour quand même en sélectionnant une commande', () => {
    render(<OrderList orders={orders} />);
    fireEvent.click(screen.getByText('Minh'));
    expect(screen.getByText('En cours de sélection : 2')).toBeTruthy();
  });
});
 ✓ OrderList > ne re-rend pas OrderRow en tapant juste une note
 ✓ OrderList > met à jour quand même en sélectionnant une commande

Essayez de supprimer useCallback et relancez le test. Le premier test échouera en disant expected 2 calls, received 4… ou plutôt recevra 4 appels au lieu de 2. C’est la preuve claire que le hook fonctionne. Déclarez onRender en dehors et passez la même référence, sinon ce prop détruira memo.

Au-delà des tests, vous devriez utiliser l’onglet Profiler de React DevTools avec l’option « Highlight updates » pour voir visuellement quel composant se re-rend.

6. Quand ne PAS l’utiliser

La mémoïsation n’est pas gratuite. React doit stocker l’ancienne valeur, stocker le tableau de dépendances et le comparer à chaque rendu. Le code est aussi plus difficile à lire. Vous devriez l’ignorer dans ces cas :

  • Calculs bon marché : ajouter quelques nombres, concaténer des chaînes, map sur quelques dizaines d’éléments.
  • Valeurs primitives : useMemo(() => a + b, [a, b]) retourne un nombre. Les nombres sont déjà comparés par valeur, donc la mémoïsation n’aide pas.
  • Callback passé à un élément DOM ou à un composant enfant sans memo.
  • Le composant enfant reçoit toujours une prop qui change. Par exemple <OrderRow order={{ ...o }} /> ou en passant children comme JSX. JSX est aussi un nouvel objet à chaque rendu, donc memo le trouvera toujours différent.
  • Possibilité de restructurer plutôt que mémoriser. Descendre l’état plus près du lieu d’utilisation, ou passer des composants lourds via children, supprime généralement les rendus inutiles de façon bien plus radicale.

Remarque : Ne comptez pas sur useMemo pour garantir la logique correcte. React a le droit de nettoyer le cache, par exemple dans certaines situations avec Suspense ou à l’avenir. Votre code doit fonctionner correctement sans mémoïsation. Ce hook n’est qu’une optimisation des performances. De même, ne placez pas d’effets secondaires comme les appels API ou l’écriture dans localStorage dans useMemo. Leur place est dans useEffect ou dans un event handler.

L’erreur la plus courante est de manquer une dépendance, ce qui crée une fermeture obsolète :

const handleSave = useCallback(() => {
  saveOrder({ ...order, note });
}, [order]); // manque `note`

handleSave utilisera toujours la valeur note de sa création initiale. Activez la règle react-hooks/exhaustive-deps de eslint-plugin-react-hooks et n’ignorez pas ses avertissements.

7. Ce que React Compiler change

React Compiler, stable depuis fin 2025, analyse vos composants au moment du build et insère automatiquement de la mémoïsation à un niveau de détail plus fin que ce que vous écririez normalement. Pour le tester dans un projet Vite ou Babel :

npm install -D babel-plugin-react-compiler

Quand le compiler est activé, vous n’avez presque plus besoin d’écrire useMemo, useCallback ou memo vous-même. Les anciens hooks continuent de fonctionner. Ils sont utiles quand vous avez besoin de contrôler précisément une dépendance d’un effet. Le compiler ne peut optimiser que du code qui suit les règles de React : composants purs, sans modification directe des props ou de l’état. Par conséquent, bien comprendre les deux hooks de cet article reste nécessaire pour lire l’ancien code et déboguer quand le compiler manque un composant.

8. Conclusion

useMemo et useCallback résolvent le même problème : maintenir une référence stable entre les rendus. Ils sont vraiment utiles dans trois situations :

  1. Le calcul mesurable est coûteux.
  2. La valeur ou la fonction est passée à un composant enveloppé dans React.memo.
  3. La valeur ou la fonction se trouve dans la dépendance d’un useEffect ou d’un autre hook, y compris une fonction retournée d’un custom hook.

Au-delà de ces cas, gardez le code simple. Mesurez avec le Profiler, prouvez avec des tests, et envisagez d’activer React Compiler pour laisser l’outil faire ce travail à votre place.

reacthooksusememousecallbackperformance