reactanimasjoner

Optimistic UI i React: grensesnittet som bevisst lyver

Optimistic UI i React: grensesnittet som bevisst lyver

I artikkelen om mikrointeraksjoner skrev jeg om Doherty-terskelen – hvis et system svarer på under 400 ms, oppfatter brukeren det som umiddelbart. Mikrointeraksjoner lukker dette gapet ved å animere knappens tilstand mens man venter på at serversvaret skal komme tilbake. Optimistic UI går et skritt lenger: den animerer ikke bare ventetiden, den forutsetter på forhånd at handlingen vil lykkes, og viser umiddelbart sluttresultatet – hjertet i «Lik»-knappen fylles med en gang, telleren øker med én, selv om requesten til serveren nettopp har startet. Grensesnittet forteller brukeren noe som ennå ikke er sant – og forutsetter at det snart vil bli det.

Dette fungerer utmerket så lenge forutsetningen stemmer – og i praksis, for handlinger som «lik», «legg til i favoritter» eller «merk som lest», stemmer den i det store flertallet av tilfeller. Problemet begynner der de fleste implementasjoner prøver å gjøre dette manuelt: setState ved klikk, catch med motsatt setState ved feil. Dette ser uskyldig ut i en isolert test med ett enkelt klikk. Det sklir ut når brukeren klikker knappen to ganger raskt etter hverandre – nøyaktig det samme fjerde elementet i Saffers modell (løkker og modus) fra artikkelen om mikrointeraksjoner, bare at denne gangen er feilen ikke kosmetisk, men fører til en reelt inkonsistent tilstand.

Hvorfor manuell rollback sklir ut ved to klikk

Se for deg den mest opplagte, manuelle implementasjonen: et klikk bytter umiddelbart liked i den lokale tilstanden, sender en request, og i catch tilbakestilles liked til verdien fra før klikket, som ble lagret i en variabel før requesten ble sendt.

jsx

// Naiv rollback – fungerer for ett klikk, ødelegges ved to
const handleClick = async () => {
const previousLiked = liked; // frossent øyeblikksbilde av tilstanden FØR klikket
setLiked(!liked);
try {
await toggleLike(postId, !liked);
} catch {
setLiked(previousLiked); // tilbakestiller til tilstanden før DETTE klikket
}
};

Scenarioet som ødelegger dette: brukeren klikker «lik» (start på request A, previousLiked = false), og etter 100 ms klikker «avlik» (start på request B, previousLiked = true, fordi den lokale tilstanden allerede har rukket å endre seg til true). Hvis request A feiler, mens request B fortsatt pågår eller allerede har lyktes, vil catch for request A tilbakestille tilstanden til false – og dermed overskrive effekten av klikk B, som brukeren gjorde bevisst og som kan ha lyktes allerede. Rollbacken til én request slettet resultatet av den andre, fordi begge opererte på den samme, delte tilstandsvariabelen, uten noen kunnskap om hverandre. Dette er nøyaktig den typen feil som ikke dukker opp i en rask manuell test – den dukker opp i produksjon, når en reell bruker klikker raskere enn utvikleren forestilte seg mens koden ble skrevet.

Hvordan useOptimistic unngår dette problemet strukturelt, ikke med en lapp

useOptimistic i React er ikke en mer bekvem innpakning rundt setState pluss try/catch. Det er en annen konseptuell modell: i stedet for å holde ett enkelt, muterbart tilstandsfelt som du manuelt bytter og manuelt tilbakestiller, beregner useOptimistic et midlertidig overlegg fortløpende, basert på den nåværende, ekte tilstanden, synlig kun så lenge den aktuelle operasjonen (transition) varer. Når operasjonen er ferdig – med suksess eller feil – forsvinner overlegget av seg selv, og avdekker det som på det tidspunktet er den ekte tilstanden. Du skriver ingen rollback-kode. Rollback er en naturlig konsekvens av at det midlertidige overlegget forsvinner, ikke en egen kodesti du kan glemme å håndtere eller håndtere feil.

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 manuell rollback i catch – hvis action-en kaster en feil,
// forkaster React uansett overlegget når transition er ferdig,
// og avdekker den faktiske "liked"-verdien sendt inn 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 avgjørende forskjellen fra den manuelle versjonen: overlegget som useOptimistic beregner, er basert på state sendt inn som første argument – altså den nåværende, ekte tilstanden fra props (vanligvis hentet fra en Server Action og revalidatePath, som i artikkelen om Server Actions) – og ikke på et separat, frosset øyeblikksbilde lagret manuelt i en lokal variabel. Når brukeren klikker to ganger etter hverandre, beregner hvert kall til setOptimistic et nytt overlegg mot den nåværende tilstanden, så det andre klikket kan ikke bli utilsiktet overskrevet av en forsinket rollback fra det første – det finnes ingen egen kodesti «tilbakestill til den lagrede verdien» som kan ta feil av hvilket tidspunkt det egentlig var snakk om.

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

Ikke enhver handling passer for dette mønsteret, og det er ikke et spørsmål om smak, men om konsekvensene av en feilaktig forutsetning. «Lik», «legg til i favoritter», «merk som lest» – billige i konsekvens, lette å reversere, lykkes nesten alltid. Betaling, uigjenkallelig sletting av en konto, sending av en melding til en annen person – her er det risikabelt å optimistisk vise suksess før det faktisk har skjedd: brukeren tar nye beslutninger basert på informasjon som kan vise seg å være falsk, og å reversere en slik «løgn» noen sekunder senere er langt mer forvirrende enn en vanlig spinner som bare varer litt lenger.

Selv der optimistic UI gir mening, gjenskaper en stille rollback uten noen informasjon nøyaktig det persepsjonsproblemet den skulle løse. Brukeren så hjertet fylles, oppfattet saken som avsluttet, flyttet oppmerksomheten et annet sted – og et øyeblikk senere går hjertet stille tilbake til tom tilstand, uten forklaring. Dette er den samme «mangel på løkker og modus»-feilen fra Saffers modell, bare forskjøvet i tid: det var tilbakemelding på suksess, men ingen tilbakemelding på feil. Løsningen er det samme verktøyet jeg beskrev i artikkelen om tilgjengelighet – aria-live="polite" eller role="alert" ved en feilmelding, slik at tilbakestillingen av tilstanden ikke er stille verken visuelt eller lydmessig for noen som ikke akkurat ser nøye på denne knappen i det øyeblikket den reverseres.

Fallgruver og gode praksiser

  • setOptimistic må kalles inne i en transition. Utenfor startTransition (eller en action sendt til useActionState/<form action>) vil useOptimistic ikke fungere som forventet – mekanismen er uløselig knyttet til livssyklusen til en konkret transition, ikke til et hvilket som helst tidspunkt under rendering.
  • Oppdateringsfunksjonen som sendes til useOptimistic må være ren og billig. Den kjøres synkront under rendering for å beregne overlegget – komplekse beregninger eller sideeffekter her er en kategorifeil, ikke bare et ytelsesspørsmål.
  • Ikke skjul feil i stillhet. En stille rollback uten melding er verre enn ingen optimistisk oppdatering i det hele tatt – brukeren får en falsk bekreftelse, og deretter ingen informasjon om at noe likevel gikk galt.
  • Reserver mønsteret for billige og reversible handlinger. Der kostnaden ved en feilaktig forutsetning er høy (betalinger, ugjenkallelige operasjoner), er et vanlig lastetilstand med en klar bekreftelse i etterkant et bedre valg enn et grensesnitt som gjetter på forhånd.

Oppsummering

Optimistic UI er ikke et animasjonstriks – det er en bevisst forutsetning om at en handling vil lykkes, vist til brukeren før serveren har bekreftet det. Manuell implementasjon via setState og catch fungerer helt til noen klikker raskere enn utvikleren forutså – da kan rollbacken til én operasjon overskrive resultatet av en annen, fordi begge deler det samme, frosne øyeblikksbildet av tilstanden. useOptimistic fjerner denne feilklassen strukturelt: overlegget beregnes alltid mot den nåværende, ekte tilstanden, ikke mot en separat lagret verdi, så rollback er ikke kode du skriver og kan skrive feil – det er en naturlig konsekvens av at en midlertidig forutsetning forsvinner. Igjen står én beslutning som ingen API tar for deg: om den aktuelle handlingen er billig og reversibel nok til at det i det hele tatt gir mening å lyve om den, om så bare et øyeblikk.