reactanimationer

Optimistic UI i React: en grænseflade, der bevidst lyver

Optimistic UI i React: en grænseflade, der bevidst lyver

I artiklen om mikrointeraktioner skrev jeg om Doherty-tærsklen – hvis et system svarer på under 400 ms, opfatter brugeren det som øjeblikkeligt. Mikrointeraktioner lukker det hul med en animation af knappens tilstand, før svaret fra serveren når frem. Optimistic UI går et skridt videre: den animerer ikke bare ventetiden, den antager på forhånd, at handlingen vil lykkes, og viser straks slutresultatet – hjertet i "synes godt om"-knappen fyldes øjeblikkeligt ud, tælleren stiger med én, selvom requestet til serveren netop er startet. Grænsefladen fortæller brugeren noget, der endnu ikke er sandt – og går ud fra, at det snart vil være det.

Det virker glimrende, så længe antagelsen holder stik – og i praksis, for handlinger som "synes godt om", "føj til favoritter" eller "markér som læst", holder den i langt de fleste tilfælde. Problemet opstår dér, hvor de fleste implementeringer prøver at gøre det manuelt: setState ved klik, catch med et modsat setState ved fejl. Det ser uskyldigt ud i en isoleret test med ét enkelt klik. Det går galt, når brugeren klikker på knappen to gange hurtigt efter hinanden – præcis det samme fjerde element fra Saffers model (loops og modes) fra artiklen om mikrointeraktioner, bare denne gang er fejlen ikke kosmetisk, men fører til en reelt inkonsistent tilstand.

Hvorfor en manuel rollback bryder sammen ved to klik

Forestil dig den mest oplagte, manuelle implementering: et klik skifter straks liked i den lokale tilstand, sender et request, og i catch rulles liked tilbage til værdien før klikket, som blev gemt i en variabel, inden requestet blev sendt.

jsx

// Naiv rollback – virker for ét klik, går galt ved to
const handleClick = async () => {
const previousLiked = liked; // frosset øjebliksbillede af tilstanden FØR klikket
setLiked(!liked);
try {
await toggleLike(postId, !liked);
} catch {
setLiked(previousLiked); // ruller tilbage til tilstanden før DETTE klik
}
};

Scenariet, der ødelægger det: brugeren klikker "synes godt om" (request A starter, previousLiked = false), og efter 100 ms klikker "synes ikke godt om" (request B starter, previousLiked = true, fordi den lokale tilstand allerede nåede at ændre sig til true). Hvis request A fejler, mens request B stadig kører eller allerede er lykkedes, vil catch fra request A rulle tilstanden tilbage til false – og dermed overskrive effekten af klik B, som brugeren bevidst foretog, og som måske allerede var lykkedes. Rollback fra det ene request slettede resultatet af det andet, fordi begge opererede på den samme, delte tilstandsvariabel, uden nogen viden om hinanden. Det er præcis den type fejl, der ikke viser sig i en hurtig manuel test – den viser sig i produktion, når en reel bruger klikker hurtigere, end udvikleren forestillede sig, da koden blev skrevet.

Hvordan useOptimistic undgår problemet strukturelt, ikke med en lap

useOptimistic i React er ikke en mere bekvem indpakning af setState plus try/catch. Det er en anden konceptuel model: i stedet for at holde ét, muterbart tilstandsfelt, du manuelt skifter og manuelt ruller tilbage, beregner useOptimistic løbende et midlertidigt lag oven på den aktuelle, reelle tilstand, synligt kun i løbet af den pågældende operation (transition). Når operationen afsluttes – med succes eller fejl – forsvinder laget af sig selv og afslører det, der i det øjeblik er den reelle tilstand. Du skriver ikke rollback-kode. Rollback er en naturlig konsekvens af, at det midlertidige lag forsvinder, ikke en separat kodesti, du kan glemme at håndtere eller håndtere forkert.

jsx

"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLikeAction } from "./actions";
const LikeButton = ({ postId, liked, likeCount }) => {
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(
{ liked, likeCount },
(state, nextLiked) => ({
liked: nextLiked,
likeCount: state.likeCount + (nextLiked ? 1 : -1),
}),
);
const handleClick = () => {
const nextLiked = !optimistic.liked;
startTransition(async () => {
setOptimistic(nextLiked);
await toggleLikeAction(postId, nextLiked);
// Ingen manuel rollback i catch – hvis aktionen kaster en fejl,
// afviser React alligevel laget, når transitionen er afsluttet,
// og afslører den reelle "liked" sendt via props.
});
};
return (
<button
type="button"
onClick={handleClick}
aria-pressed={optimistic.liked}
className={isPending ? "like-button--pending" : ""}
>
<span aria-hidden="true">{optimistic.liked ? "♥" : "♡"}</span>
{optimistic.likeCount}
</button>
);
};
export default LikeButton;

Den afgørende forskel i forhold til den manuelle version: laget, useOptimistic beregner, bygger på state, sendt som første argument – altså den aktuelle, reelle tilstand fra props (typisk hentet fra en Server Action og revalidatePath, som i artiklen om Server Actions) – og ikke på et separat, manuelt gemt øjebliksbillede i en lokal variabel. Klikker brugeren to gange hurtigt efter hinanden, beregner hvert kald til setOptimistic et nyt lag i forhold til den aktuelle tilstand, så det andet klik ikke ved et uheld kan overskrives af en forsinket rollback fra det første – der findes ingen separat "rul tilbage til den gemte værdi"-kodesti, der kan tage fejl af, hvilket tidspunkt der egentlig var tale om.

Hvornår optimistic UI er en god idé, og hvornår det er en farlig løgn

Ikke enhver handling egner sig til dette mønster, og det er ikke en smagssag, men et spørgsmål om konsekvenser af en fejlagtig antagelse. "Synes godt om", "føj til favoritter", "markér som læst" – billige i konsekvens, let reversible, lykkes næsten altid. Betaling, uigenkaldelig sletning af en konto, afsendelse af en besked til en anden person – her er det risikabelt at vise en succes optimistisk, før den faktisk er indtruffet: brugeren træffer yderligere beslutninger baseret på information, der kan vise sig at være falsk, og at trække sådan en "løgn" tilbage nogle sekunder senere er langt mere forvirrende end en almindelig spinner, der bare tager lidt længere tid.

Selv dér, hvor optimistic UI giver mening, genskaber en stille rollback uden nogen besked præcis det opfattelsesproblem, den skulle løse. Brugeren så hjertet fyldt ud, betragtede sagen som afsluttet, flyttede opmærksomheden andetsteds hen – og et øjeblik senere vender hjertet stille tilbage til sin tomme tilstand, uden forklaring. Det er den samme fejl med "manglende loops og modes" fra Saffers model, blot forskudt i tid: der var feedback ved succes, men ikke ved fejl. Løsningen er det samme værktøj, jeg beskrev i artiklen om tilgængelighed – aria-live="polite" eller role="alert" ved en fejlbesked, så tilbagerulningen af tilstanden ikke er stille, hverken visuelt eller lydmæssigt, for nogen, der lige ikke kigger nøje på den knap i det øjeblik, den ruller tilbage.

Faldgruber og god praksis

  • setOptimistic skal kaldes inden i en transition. Uden for startTransition (eller en action givet til useActionState/<form action>) virker useOptimistic ikke som forventet – mekanismen er uløseligt knyttet til livscyklussen for en konkret transition, ikke til et vilkårligt renderingsøjeblik.
  • Opdateringsfunktionen givet til useOptimistic skal være ren og billig. Den eksekveres synkront under rendering for at beregne laget – komplekse beregninger eller sideeffekter her er en fejl af hele kategorien, ikke kun et ydelsesspørgsmål.
  • Skjul ikke en fejl i tavshed. En stille rollback uden besked er værre end slet ingen optimistisk opdatering – brugeren får en falsk bekræftelse og derefter ingen information om, at noget alligevel gik galt.
  • Reserver mønsteret til handlinger, der er billige og reversible. Hvor omkostningen ved en fejlagtig antagelse er høj (betalinger, uigenkaldelige handlinger), er et almindeligt indlæsningsstadie med tydelig bekræftelse bagefter et bedre valg end en grænseflade, der gætter på forhånd.

Opsummering

Optimistic UI er ikke et animationstrick – det er en bevidst antagelse om, at en handling vil lykkes, vist til brugeren, før serveren har bekræftet det. En manuel implementering med setState og catch virker, indtil nogen klikker hurtigere, end udvikleren regnede med – så kan rollback fra én operation overskrive resultatet af en anden, fordi begge deler det samme, frosne øjebliksbillede af tilstanden. useOptimistic fjerner den fejlklasse strukturelt: laget beregnes altid ud fra den aktuelle, reelle tilstand, ikke ud fra en separat gemt værdi, så rollback ikke er kode, du skriver og kan skrive forkert – det er en naturlig konsekvens af, at en midlertidig antagelse forsvinder. Tilbage står én beslutning, intet API kan tage for dig: om den pågældende handling er billig og reversibel nok til, at det overhovedet giver mening at lyve om den, selv et øjeblik.