Accueil / Articles / Retour arrière de useOptimistic : Cinq modes d’échec dans les actions serveur de Next.js

Retour arrière de useOptimistic : Cinq modes d’échec dans les actions serveur de Next.js

Comprendre pourquoi useOptimistic rétablit silencieusement l’interface utilisateur sans expliquer les échecs aux utilisateurs, à travers cinq modes d’échec des actions serveur testés et une solution fonctionnelle.

4375 mots

Le rétablissement automatique fonctionne exactement comme promis. En revanche, afficher le message d’erreur à l’utilisateur ne fonctionne pas de la même manière. J’ai délibérément déclenché cinq scénarios d’échec différents sur un bouton Server Action dans Next.js et j’ai enregistré ce qui s’est réellement affiché à l’écran.

Le rétablissement ne coûte rien. En revanche, communiquer l’erreur en coûte. L’état payant change, puis revient silencieusement — sans aucune explication que l’utilisateur puisse vraiment lire.

La plupart des tutoriels traitent useOptimistic comme un bouton d’annulation gratuit. On clique, l’interface change, la requête échoue, et l’interface revient à son état initial. Rien de plus.

J’ai voulu vérifier si cette promesse tenait la route lorsque les choses deviennent complexes. J’ai donc créé une petite liste de factures dans Next.js App Router — cinq lignes, chacune reliée à sa propre action serveur — et j’ai simulé cinq manières différentes dont cette action pouvait échouer dans du code réel. Pas de cas limites artificiels : les erreurs qui se glissent en production sans que personne ne s’en aperçoive.

Au cours de cinq exécutions par mode d’échec, trois ont réinitialisé correctement l’interface. Deux ont laissé l’interface afficher des informations fausses. Le réinitialisation automatique fonctionne lorsque l’action lance une exception. Il ne fonctionne pas lorsque l’action renvoie discrètement quelque chose comme { ok: false } au lieu de lancer une exception — ce schéma constitue un moyen subtil de tromper l’utilisateur sans intention malveillante.

À titre de référence, cela a été développé à partir de la version 15.5.2 de Next.js, associée à React 19.1.1, compilée avec TypeScript 5.9.2, et exécutée sous la ligne Node 22.x sur une machine Linux. Les chiffres indiqués proviennent de cet environnement précis. Si vous exécutez à nouveau ce même ensemble d’outils sur une machine différente, les temps de traitement pourraient varier légèrement, mais la nature de chaque échec devrait rester identique.

Que promettent réellement les documents

La page de référence officielle pour useOptimistic affirme clairement : la valeur optimiste n’est affichée que tant qu’une Action est encore en cours d’exécution ; une fois celle-ci finalisée, React passe à l’affichage de la valeur réelle que contient actuellement le value.

Ce texte explique également ce qui se passe en cas de problème. En bref : une erreur non capturée à l’intérieur d’une Action permet néanmoins à la Transition en attente de se terminer normalement. Comme le code environnant n’écrase généralement dans la vraie valeur value qu’après une appel réussi, une erreur signifie que cette valeur n’a jamais été mise à jour — donc, une fois la Transition terminée, React affiche simplement la même interface utilisateur que celle que l’utilisateur voyait avant même le clic. Les documents indiquent qu’il vous incombe de capturer cette erreur vous-même si vous souhaitez afficher un message à l’utilisateur ; React ne le fera pas à votre place.

Deux détails supplémentaires sont importants ici :

  1. Le setteur optimiste doit être exécuté à l’intérieur d’une Action ou de la fonction startTransition. S’il est exécuté en dehors de celles-ci, React enregistre un avertissement, et l’interface utilisateur optimiste n’apparaît que brièvement avant de disparaître.
  • Le retrait n’est pas quelque chose que l’on déclenche — c’est simplement ce qui se produit par défaut lorsque la transition est terminée et que la valeur de base n’a jamais été modifiée.
  • Ce deuxième point constitue en réalité l’idée centrale de tout cela. Revenir à l’état initial de l’interface est gratuit ; c’est à vous de dire au utilisateur pourquoi. Le mécanisme affiche une valeur prévue tant qu’une action est en attente, puis s’adapte à la véritable valeur du parent. Si vous lancez une action sans modifier cette valeur de base, vous obtenez un retrait. Si l’action se résout avec succès sans modification, vous avez également un retrait. Si elle se résout avec succès mais que la valeur de base est mise à jour avec un résultat incorrect, vous obtenez alors un état fantôme — une interface affichant quelque chose qui ne s’est jamais réellement produit sur le serveur.

    Mini-app : commutateur de facture payée

    Au lieu d’un exemple simpliste, l’application de test imite une page de facturation, car c’est précisément là qu’une étiquette « Payé » incorrecte déclenche un appel de la part du service de recouvrement.

    Voici sa structure :

    • Une page RSC charge cinq factures provenant d’un stock en mémoire (INV-1001 à INV-1005), toutes non payées au départ.
    • Chaque ligne est affichée en tant que composant client indépendant contenant un booléen optimiste.
    • Clic sur le bouton de basculement appelle une action serveur avec une valeur booléenne paid explicite.
    • revalidatePath('/invoices') ne s’exécute que lorsque l’action réussit.
    • Chaque ligne gère un compteur d’affichage qui augmente à chaque mise à jour. Le mode strict est désactivé afin que ce compteur ne soit pas gonflé par des appels doubles.
  • L’action comprend une instruction artificielle await sleep(400) afin que la fenêtre optimiste soit suffisamment longue pour être observée visuellement et mesurée à l’aide de performance.now().
  • // app/invoices/page.tsx
    import { getInvoices } from '@/lib/invoices';
    import { InvoiceRow } from './invoice-row';
    
    export default async function InvoicesPage() {
      const invoices = await getInvoices();
      return (
        <ul>
          {invoices.map((inv) => (
            <InvoiceRow key={inv.id} invoice={inv} />
          ))}
        </ul>
      );
    }
    

    Règle pour chaque exécution de test : réinitialiser le stock des factures en état non payé, activer FAIL_MODE, cliquer sur le bouton d’activation une fois (deux fois pour le mode cinq), attendre 800 ms après que l’action soit terminée, vérifier ensuite l’étiquette du bouton, examiner data-renders, noter d’éventuels avertissements dans la console, et prendre une capture d’écran. Chaque mode a été exécuté cinq fois, toujours sur la même identifiant de facture, sans utilisation de React Query ni de couche de mise en cache externe — uniquement des props RSC, useOptimistic, et une seule action serveur.

    Le composant de la ligne « chemin heureux » ressemble à quelque chose tiré d’un tutoriel introductif :

    // app/invoices/invoice-row.tsx — broken happy-tutorial version
    'use client';
    
    import { useOptimistic, startTransition, useRef } from 'react';
    import { togglePaid } from './actions';
    import type { Invoice } from '@/lib/invoices';
    
    export function InvoiceRow({ invoice }: { invoice: Invoice }) {
      const renders = useRef(0);
      renders.current += 1;
    
      const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
    
      function onToggle() {
        startTransition(async () => {
          setOptimisticPaid(!optimisticPaid);
          await togglePaid(invoice.id, !optimisticPaid);
          // hope revalidatePath inside the action fixes the base prop
        });
      }
    
      return (
        <li data-renders={renders.current}>
          <span>{invoice.number}</span>
          <button type="button" onClick={onToggle} aria-pressed={optimisticPaid}>
            {optimisticPaid ? 'Paid' : 'Unpaid'}
          </button>
        </li>
      );
    }
    

    Et voici l’action serveur elle-même, avec un interrupteur de défaillance que le jeu de tests peut activer sur demande :

    // app/invoices/actions.ts
    'use server';
    
    import { revalidatePath } from 'next/cache';
    import { z } from 'zod';
    import { setPaid } from '@/lib/invoices';
    
    const ToggleSchema = z.object({
      id: z.string().uuid(),
      paid: z.boolean(),
    });
    
    export type ToggleResult =
      | { ok: true }
      | { ok: false; code: 'VALIDATION' | 'BIZ'; message: string };
    
    let FAIL_MODE:
      | 'none'
      | 'throw'
      | 'soft'
      | 'zod'
      | 'race' = 'none';
    
    export function __setFailMode(mode: typeof FAIL_MODE) {
      FAIL_MODE = mode;
    }
    
    export async function togglePaid(
      id: string,
      paid: boolean,
    ): Promise<ToggleResult> {
      await new Promise((r) => setTimeout(r, 400)); // visible optimistic window
    
      if (FAIL_MODE === 'throw') {
        throw new Error('DB write failed');
      }
    
      const parsed = ToggleSchema.safeParse({ id, paid });
      if (!parsed.success || FAIL_MODE === 'zod') {
        return {
          ok: false,
          code: 'VALIDATION',
          message: 'Invalid toggle payload',
        };
      }
    
      if (FAIL_MODE === 'soft') {
        return { ok: false, code: 'BIZ', message: 'Invoice locked' };
      }
    
      await setPaid(id, paid);
    
      if (FAIL_MODE === 'race') {
        // succeed, revalidate, then a second overlapping call fights it
        revalidatePath('/invoices');
        return { ok: true };
      }
    
      revalidatePath('/invoices');
      return { ok: true };
    }
    

    Mode de défaillance 1 — L’action serveur lance une exception

    FAIL_MODE = 'throw'. L’action lance une exception après un délai de 400 ms. Rien du côté client ne la capture. C’est exactement le scénario décrit dans la documentation.

    Attendu : la transition se termine, la valeur de invoice.paid ne change jamais, la couche optimiste disparaît, et le bouton redevient affiché comme « Non payé ».

    Observé (5 sur 5 exécutions) :

    • t=0 ms : le clic est enregistré, l’étiquette passe immédiatement à « Payé » (la mise à jour optimiste)
    • t≈400 ms : l’exception apparaît, mettant fin à la transition
    • t≈410 ms : l’étiquette revient à « Non payé »
  • Bilan moyen de rendu pour la ligne : 4 (chargement initial, mise à jour optimiste, annulation, puis une passe silencieuse RSC)
  • Message d’erreur visible pour l’utilisateur : Aucun
  • Sortie de la console : une erreur d’action serveur non gérée, affichée sous forme de boîte rouge Next.js en mode développement
  • fonctionne comme décrit. Gestion des erreurs : defectueuse. Du point de vue de l’utilisateur, la facture s’est affichée comme « Payée » pendant environ 400 millisecondes avant de revenir à « Non payée » sans aucune explication. Techniquement, cela correspond à une « annulation automatique ». En pratique, c’est inutilisable dans un produit réel. Si la seule chose que vous avez retenue de la documentation, c’est « elle annule en cas d’échec », c’est bien ce résultat que vous finirez par livrer.

    J’ai également vérifié si le composant du serveur parent se réaffichait. Ce n’a pas été le cas — invoice.paid n’a jamais changé de position. Le retour à la normale s’est produit uniquement parce que l’overlay optimiste avait disparu, et non à cause d’une mise à jour inverse. Il n’y a eu aucune appel à setPaid(false) du côté client. La propriété de base est restée exactement à sa position initiale, si bien que, une fois la couche optimiste supprimée, l’interface affichait simplement à nouveau sa valeur de base. C’est tout le mécanisme, et cela devient important lorsque nous abordons le cas de défaillance légère ci-dessous.

    Mode de défaillance 2 — Défaillance légère { ok: false }, sans erreur

    C’est là que les équipes se trompent le plus souvent. Au lieu de lancer une exception, de nombreuses implémentations renvoient un résultat structuré afin que le chemin d’erreur ait un type approprié. Choix raisonnable — sauf si le code client ne vérifie jamais cette valeur de retour, la transition se termine néanmoins avec succès du point de vue de React.

    // still the happy-tutorial handler
    startTransition(async () => {
      setOptimisticPaid(!optimisticPaid);
      await togglePaid(invoice.id, !optimisticPaid); // returns { ok: false }
    });
    

    FAIL_MODE = 'soft'. Le stockage sous-jacent reste inchangé. revalidatePath n’est jamais déclenché. L’action se termine avec { ok: false, code: 'BIZ', message: 'Invoice locked' } — aucune exception n’est levée.

    Ce à quoi on s’attendrait si l’on comptait sur le principe « le rollback a lieu automatiquement en cas d’échec » : l’interface utilisateur revient à son état initial car la mutation n’a pas réussi.

    Observé avec le gestionnaire minimal ci-dessus (5/5 exécutions) :

    • L’état optimiste passe en mode Payé
  • La transition s’effectue normalement (la promesse est remplie)
  • La propriété de base reste false
  • L’overlay disparaît une fois la transition terminée, de sorte que l’étiquette redevient « Non payé »
  • Nombre moyen d’affichages : 4
  • Message affiché à l’utilisateur : toujours Aucun, car le res retourné n’a jamais été lu
  • Même donc la version « bien comportée » revient correctement en arrière. Une défaillance mineure ne fait pas en sorte que la valeur optimiste reste en place d’elle-même — l’overlay disparaît dès que l’action se résout, qu’elle ait généré une erreur ou non. L’insistance de la documentation sur les erreurs décrit le cas typique, mais pas le seul. Toute action qui se résout sans modifier l’état de base reviendra à la normale.

    D’où vient donc réellement cette interface fantôme ?

    Cela se produit lorsque le gestionnaire tente d’être « intelligent » en mettant à jour l’état de base local dès que la promesse est résolue — par exemple, si vous reflétez le statut de paiement dans un useState et que vous le définissez avant de vérifier ok:

    // the lie I actually shipped once
    startTransition(async () => {
      setOptimisticPaid(true);
      const res = await togglePaid(id, true);
      setLocalPaid(true); // always — "the action finished"
      if (!res.ok) setError(res.message); // too late, base already moved
    });
    

    Avec ce schéma en place (5/5 exécutions) :

    • Le bouton reste bloqué sur Paid même après l’échec
    • Un message d’erreur peut s’afficher en dessous de la ligne
    • Le stock RSC sous-jacent conserve encore le statut Unpaid
    • La prochaine navigation, ou toute révalidation ultérieure, ramène la ligne à son état initial — créant un état fantôme qui persiste jusqu’à ce que quelque chose force un renouvellement

    Pour résumer l’évaluation des scores : une défaillance légère sans mise à jour de la base locale signifie que le rollback fonctionne mais aucun retour d’information n’est fourni à l’utilisateur. Une défaillance légère associée à une mise à jour rapide de la base locale provoque un UI fantôme. C’est ce deuxième cas qui apparaît plus tard comme mode de défaillance 2 sur le tableau de scores. Le véritable défaut n’est pas la forme { ok: false } elle-même — c’est le fait de considérer « l’action est terminée » comme équivalent à « l’action a réussi ».

    Mode de défaillance 3 — Validation Zod, erreur structurée, pas d’exception

    FAIL_MODE = 'zod'. Sur le plan structurel, cela correspond au mode 2, mais il est déclenché différemment. Une appel à safeParse échoue (ou le branchement d’erreur est activé), et l’action renvoie { ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' }. Aucune exception n’est levée, et revalidatePath est ignoré.

    Ce cas mérite un traitement distinct, car les équipes ont tendance à considérer les erreurs de validation comme intrinsèquement « sûres » — elles sont prévues, anticipées et gérées délibérément. Les utilisateurs ne perçoivent aucune de ces subtilités. Du leur point de vue, l’élément visuel ne fait que clignoter avant de devenir silencieux.

    Observé (5/5 exécutions) avec un client qui ne met à jour l’état de base que lorsque ok est vrai :

    • L’état « Payé » apparaît, la transition se termine, puis il revient à l’état « Non payé »
    • Bilan moyen de rendus : 4
    • Durée pendant laquelle l’étiquette « Payé » était visible : environ 400–420 ms
    • Message affiché à l’utilisateur : Aucun, sauf si le code prend explicitement en compte la valeur de res

    Les erreurs de validation peuvent sembler plus fiables car TypeScript impose leur structure, mais cela ne se traduit pas par une meilleure interface — au contraire, le silence est plus marqué. Le retrait en arrière se comporte de la même manière, et le silence reste identique. Si le formulaire avait utilisé useActionState et mappé la valeur retournée vers son state, le message aurait pu survivre à la transition. Le composant de ligne simple de style tutoriel ne fait pas cela.

    Une précision importante : exécuter la validation Zod côté client avant d’appeler setOptimistic empêcherait l’état Paid d’être affiché. Le mode 3 concerne spécifiquement les validations côté serveur qui échouent après que l’affichage optimiste a déjà eu lieu. Cet ordre est précisément ce qui provoque le problème.

    Mode d’échec 4 — appeler addOptimistic en dehors de startTransition

    function onToggle() {
      // 🚩 outside a Transition
      setOptimisticPaid(!optimisticPaid);
      startTransition(async () => {
        await togglePaid(invoice.id, !optimisticPaid);
      });
    }
    

    La documentation met en garde précisément contre cette situation : si vous mettez à jour l’état optimiste sans l’encadrer dans une Transition ou une Action, le changement apparaîtra un instant avant de revenir presque immédiatement à sa valeur initiale, car il n’y a pas de contexte de transition pour le maintenir en place pendant que le travail sous-jacent se termine.

    Sans une Transition pour encadrer l’appel, rien ne permet de conserver la prédiction active pendant que le travail asynchrone s’exécute. React ne dispose d’aucun contexte auquel attacher la valeur optimiste, ce qui fait qu’elle revient simplement à sa valeur initiale.

    Observations (5/5) :

    • Un bref changement vers « Payé », souvent juste une seule frame, parfois deux mises à jour graphiques
    • Un retour immédiat à « Non payé », avant même que l’action de 400 ms ne soit terminée
    • Un avertissement de React dans les DevTools à chaque clic
  • Bilan des rendus : 3 (chargement initial, effet flash, réversion), suivi plus tard d’un renouvellement RSC une fois l’action réussie
  • Dans le scénario optimal, une fois que revalidatePath est exécuté, il y a un deuxième changement lorsque de nouvelles données serveur arrivent, provoquant chez l’utilisateur un léger sursaut suivi d’une confirmation différée
  • Ce n’est pas un retrait déclenché par une erreur. Il est plus juste de le décrire comme « jamais vraiment maintenu ». Le mode d’échec 4 est une erreur de codage plutôt qu’un problème côté backend, mais le résultat visuel reste le même sursaut que l’utilisateur attribuerait à de la instabilité. Il figure sur cette liste car c’est le premier élément qui tombe en panne lorsque quelqu’un refactore un gestionnaire et déplace setOptimistic avant startTransition dans le but d’améliorer la lisibilité.

    Mode d’échec 5 — revalidatePath réussi, mais course au double-clic

    Settez FAIL_MODE à 'race'. L’outil de test envoie deux clics séparés par 50 ms. Les deux déclenchent des transitions et tentent toutes deux d’atteindre l’état Payé de manière optimiste. La première écriture est terminée et révalidée ; la deuxième écriture se poursuit indépendamment.

    La fonction setPaid(id, paid) de la boutique fictive définit une valeur absolue au lieu de modifier un booléen dans la base de données, ce qui signifie que le véritable défaut réside dans la fermeture côté client :

    setOptimisticPaid(!optimisticPaid);
    await togglePaid(invdsoice.id, !optimisticPaid);
    

    Lorsque le deuxième clic est envoyé suffisamment rapidement, la valeur contenue dans cette fermeture pour optimisticPaid (ou invoice.paid) est soit la valeur antérieure au premier clic, soit une valeur lue en cours de route depuis la mise à jour optimiste encore en attente — le résultat dépend du timing exact. L’une des deux requêtes finit par envoyer paid: false.

    Résultat observé (5/5 avec la logique de commutateur obsolète) :

    • Le premier clic affiche « Payé »
    • Le deuxième clic, environ 50 ms plus tard, envoie la mauvaise valeur absolue dans au moins 4 des 5 essais
    • Deux appels distincts à la fonction revalidatePath sont lancés
    • La valeur finale provenant de la couche RSC indique Non payé, bien que l’utilisateur ait vu le statut passer à « Payé » en premier — un résultat marqué par une fluctuation suivie d’un effet fantôme
    • Dans le pire des cas, le nombre de rendus pour cette ligne atteint 9 : deux peintures optimistes, deux actions finalisées, deux mises à jour RSC, plus les rendus de base

    La solution consiste à calculer la valeur suivante à partir d’un point de départ fixé lié à l’intention du clic, et non en fonction de ce que contient par hasard la fonction closure, ainsi qu’à désactiver le commutateur tant que optimisticPaid !== invoice.paid.

    const next = !invoice.paid; // from base, not from a racing optimistic read
    startTransition(async () => {
      setOptimisticPaid(next);
      const res = await togglePaid(invoice.id, next);
    });
    

    En sautant cette correction, l’interface fantôme persiste même dans le cas de succès — rien n’est lancé, Zod ne s’exécute jamais, et pourtant l’interface continue de tromper l’utilisateur. C’est pourquoi le mode 5 doit être classé dans la colonne des interfaces fantômes plutôt que dans celle des retraits.

    Tableau de scores

    mode | trigger                         | rollback | user error | final UI vs server | avg renders | score
    -----|---------------------------------|----------|------------|--------------------|-------------|------
    1    | throw Error (500-ish)           | yes      | none       | match (Unpaid)     | 4           | rollback OK / UX fail
    2    | {ok:false} + eager base update  | no*      | maybe      | GHOST (Paid lie)   | 3           | ghost
    3    | Zod structured error, no throw  | yes      | none       | match (Unpaid)     | 4           | rollback OK / UX fail
    4    | setOptimistic outside transition| flash    | warning    | match after twitch | 3           | flash then revert
    5    | revalidate + double-fire race   | n/a      | none       | GHOST / flicker    | 7–9         | ghost
    
    * Mode 2 rolls back if you never touch base on failure. It ghosts if you set local/base on settle.
    Three clean rollbacks: 1, 3, and 2-without-eager-base.
    Two ghost paths: 2-with-eager-base, 5.
    Mode 4 is a flash, not a held ghost — still a user-visible failure.
    

    L’affirmation du sous-titre, désormais étayée par des chiffres : trois modes d’échec déclenchent un retrait, et deux laissent derrière eux une interface fantôme. Les modes 1 et 3, ainsi que le mode 2 lorsqu’il est géré avec prudence, forment le groupe des retraits. Les modes 2-eager et 5 composent le groupe des interfaces fantômes. Le mode 4 est un cas particulier — il ne maintient jamais l’overlay optimiste suffisamment longtemps pour être clairement classé dans l’une ou l’autre catégorie.

    Version corrigée : capture, survie à la transition, utilisation optionnelle de actionState

    Le rétablissement fonctionnait déjà chaque fois qu’un problème survenait. Ce qui manquait, c’était une erreur persistante au-delà de la fin de la transition, ainsi qu’une valeur de base qui ne s’actualise que lorsque le résultat est réellement ok: true.

    // app/invoices/invoice-row.tsx — fixed
    'use client';
    
    import {
      useOptimistic,
      useState,
      useTransition,
      useRef,
    } from 'react';
    import { togglePaid, type ToggleResult } from './actions';
    import type { Invoice } from '@/lib/invoices';
    
    export function InvoiceRow({ invoice }: { invoice: Invoice }) {
      const [error, setError] = useState<string | null>(null);
      const [isPending, startTransition] = useTransition();
      const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
      const renders = useRef(0);
      renders.current += 1;
    
      const pending = optimisticPaid !== invoice.paid || isPending;
    
      function onToggle() {
        const next = !invoice.paid; // absolute next from server base
        setError(null);
    
        startTransition(async () => {
          setOptimisticPaid(next);
          try {
            const res: ToggleResult = await togglePaid(invoice.id, next);
            if (!res.ok) {
              // transition will end; base unchanged → automatic revert
              // error state is plain useState → survives the revert
              setError(res.message);
              return;
            }
            // success: revalidatePath in the action updates invoice.paid
          } catch (e) {
            setError(e instanceof Error ? e.message : 'Toggle failed');
          }
        });
      }
    
      return (
        <li data-renders={renders.current}>
          <span>{invoice.number}</span>
          <button
            type="button"
            onClick={onToggle}
            disabled={pending}
            aria-pressed={optimisticPaid}
            aria-busy={pending}
          >
            {optimisticPaid ? 'Paid' : 'Unpaid'}
          </button>
          {error ? (
            <p role="alert" className="row-error">
              {error}
              <button type="button" onClick={onToggle}>
                Retry
              </button>
            </p>
          ) : null}
        </li>
      );
    }
    

    Voici ce qui a réellement changé :

    1. setOptimisticPaid n’est plus exécuté qu’à l’intérieur de startTransition, ce qui élimine complètement le mode 4.
    2. next est calculé à partir de invoice.paid, la valeur enregistrée, plutôt que d’une valeur optimiste potentiellement obsolète, ce qui atténue l’impact du mode 5.
    3. Le contrôle est désactivé avec disabled={pending} dès que l’affichage superposé et la valeur de base divergent, ce qui empêche le problème lié aux clics doubles.
    4. Un bloc try/catch entoure le cas de déploiement d’erreur, de sorte que le mode 1 affiche maintenant un message après l’exécution du rétablissement.
  • Lorsque res.ok est faux, seul l’état d’erreur est mis à jour, la valeur de base restant inchangée ; par conséquent, les modes 2 et 3 sont annulés tout en indiquant la raison.
  • L’erreur elle-même est stockée dans useState, jamais à l’intérieur de la valeur optimiste, ce qui permet à celle-ci de persister même après que l’overlay ait été supprimé.
  • Ce sixième point a nécessité une réflexion supplémentaire pour être bien compris. Si l’erreur est placée à l’intérieur du réducteur optimiste, elle disparaît dès que l’action est terminée — l’annulation efface alors votre propre message ainsi que l’interface obsolète. Un simple useState (ou l’état retourné par useActionState) constitue le canal qui continue d’exister après la disparition de l’overlay.

    Optionnel : utiliser useActionState pour la version en forme de formulaire

    Si le commutateur est implémenté sous forme de <form action>, vous pouvez laisser useActionState transmettre le dernier résultat lors de la transition, au lieu de gérer cet état manuellement :

    'use client';
    
    import { useOptimistic, useActionState } from 'react';
    import { togglePaidForm, type ToggleResult } from './actions';
    import type { Invoice } from '@/lib/invoices';
    
    const initial: ToggleResult | null = null;
    
    export function InvoiceRowForm({ invoice }: { invoice: Invoice }) {
      const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
      const [state, formAction, pending] = useActionState(
        async (_prev: ToggleResult | null, formData: FormData) => {
          const next = formData.get('next') === 'true';
          setOptimisticPaid(next); // form action is already an Action
          return togglePaidForm(String(formData.get('id')), next);
        },
        initial,
      );
    
      return (
        <form action={formAction}>
          <input type="hidden" name="id" value={invoice.id} />
          <input type="hidden" name="next" value={String(!invoice.paid)} />
          <button type="submit" disabled={pending} aria-pressed={optimisticPaid}>
            {optimisticPaid ? 'Paid' : 'Unpaid'}
          </button>
          {state && !state.ok ? (
            <p role="alert">{state.message}</p>
          ) : null}
        </form>
      );
    }
    

    Les règles restent inchangées. Le setteur s’exécute toujours à l’intérieur de l’Action. La valeur de base ne progresse toujours que suite à une révalidation réussie. L’erreur la plus récente reste stockée dans state une fois l’overlay disparu. Optez pour ce modèle lorsque le contrôle est naturellement un formulaire ; conservez la version bouton + useTransition pour les lignes de tableau compactes.

    Score après application de la correction

    Les mêmes cinq modes d’échec, avec cinq essais chacun, ont été testés à nouveau sur la ligne corrigée.

    mode | after fix                                              | ghost? | avg renders
    -----|--------------------------------------------------------|--------|------------
    1    | rollback + role="alert" with thrown message            | no     | 4
    2    | rollback + "Invoice locked" stays visible              | no     | 4
    3    | rollback + "Invalid toggle payload" stays visible      | no     | 4
    4    | eliminated (setter only in transition / form action)   | no     | n/a
    5    | button disabled while pending; absolute next value     | no*    | 3–4
    
    * Pathological manual double-submit via Playwright force-click still managed one flicker in 1/5 trials when I removed disabled. With disabled left on: 0/5 ghosts.
    

    Sur un parcours de succès normal, il y a 3 rendus : le chargement du élément, la mise à jour optimiste, puis la réconciliation RSC. Lorsqu’une erreur est accompagnée d’un message, ce nombre monte à 4 : chargement du élément, mise à jour optimiste, annulation des modifications, puis affichage de l’erreur. Ce quatrième rendu représente le coût à payer pour un élément de tableau comme celui-ci.

    Sur les cinq erreurs simulées, trois sont annulées automatiquement. Deux laissent tout de même des éléments UI fantômes : l’erreur légère qui met à jour rapidement la base de données, et le problème lié aux opérations simultanées.

    Un suivi de rendu du mode 1

    Voici les valeurs brutes de performance.now() enregistrées lors du troisième essai du mode 1, avec le Mode strict désactivé et un seul élément chargé.

    0.0     click
    2.1     optimistic commit — label=Paid, renders=2
    401.8   action throw
    403.2   transition end — label=Unpaid, renders=3
    403.9   setError in fixed build — renders=4, alert visible
    

    Lorsque l’on exécute la même tentative avec la version du tutoriel défectueuse, le processus s’arrête à renders=3 sans afficher aucun avertissement. Ce quatrième rendu est la différence fondamentale entre un composant utilisable et un composant défectueux. Le rollback en lui-même n’a jamais été la partie difficile — la vraie difficulté résidait dans le fait de maintenir un canal d’état actif après la disparition de l’overlay optimiste.

    Ce quatrième rendu est plus important que la simple réduction de quelques millisecondes sur le chemin optimiste. Un utilisateur acceptera que l’étiquette soit incorrecte pendant 400 ms tant que l’interface lui explique pourquoi. En revanche, il ne tolérera pas qu’une étiquette mente de manière affirmée pour ensuite se corriger discrètement plus tard, sans que personne ne s’en aperçoive, jusqu’à ce que quelqu’un en parle lors d’une réunion.

    Leçons à retenir pour la prochaine demande de fusion

    useOptimistic vous fournit une superposition temporaire qui ne dure que jusqu’à ce que la transition soit finalisée. Si l’action génère une erreur et que la valeur de base n’a jamais été mise à jour, React rétablit l’interface utilisateur. Une réponse douce { ok: false } sans mise à jour de la valeur de base entraîne également ce rétablissement. Ces deux comportements correspondent à ce que décrit la documentation, et ils ont tous deux été confirmés ici par des tests directs.

    La documentation ne vous fournit pas automatiquement :

    • Un message lisible pour l’utilisateur une fois que l’état est rétabli
    • Une protection contre une réponse douce { ok: false } si vous mettez à jour la valeur de base malgré tout
    • Une protection contre l’appel du setter en dehors des limites d’une transition
    • Un fonctionnement idempotent en cas de double-clic rapide combiné à revalidatePath

    Le rétablissement automatique fonctionne comme annoncé. Une bonne gestion des erreurs n’est pas gratuite. Trois des cinq cas délibérément endommagés se sont rétablis d’eux-mêmes ; les deux autres ont continué à afficher une interface obsolète jusqu’à ce que l’expression « l’action est terminée » cesse d’être considérée comme synonyme de « l’action a réussi ».

    Une courte liste à copier lors des revues de code :

    1. setOptimistic s’exécute-t-il à l’intérieur de startTransition, ou via la propriété action d’un formulaire ?
    2. La base de données (ou son miroir local) est-elle mise à jour uniquement une fois que res.ok est confirmé, ou après un succès sans erreur qui déclenche également une révalidation ?
    3. L’erreur se trouve-t-elle dans useState ou dans useActionState, séparément du réducteur optimiste ?
    4. La valeur suivante est-elle calculée à partir de l’état de base du serveur, avec le contrôle désactivé tant que l’action est en attente ?
  • Quelqu’un a-t-il vraiment testé à la fois le chemin de lancer et le chemin { ok: false } dans un navigateur, et pas seulement le mécanisme de basculement normal ?
  • Si l’exemple d’un tutoriel s’arrête à l’appel de setOptimistic et à l’attente de la réponse, il propose alors une version avec échec silencieux. Capturez l’erreur générée, examinez son résultat, et stockez les erreurs dans useState ou useActionState. Désactivez le contrôle chaque fois que l’affichage et le serveur sont en désaccord. En faisant cela, le mécanisme de réversion fourni par React devient vraiment utilisable pour un utilisateur réel.

    Lectures complémentaires

  • Pourquoi les Server Actions de Next.js nécessitent des autorisations dans chaque corps de fonction — Un cas d’attaque par prise de contrôle du compte détaillé montre comment les Server Actions non authentifiés de Next.js exposent des opérations privilégiées, et où le contrôle d’autorisation doit être placé pour y remédier.
  • Cinq mesures de sécurité pour le frontend que tout app React et Next.js a besoin — Pourquoi les apps React et Next.js en production utilisent des cookies HttpOnly, CSP, DOMPurify, des en-têtes de sécurité et les règles NEXT_PUBLIC_, ainsi quels types d’attaques chacune bloque.