Optimistic UI i React: gränssnittet som medvetet ljuger
I artikeln om mikrointeraktioner skrev jag om Dohertys tröskel – om ett system svarar på under 400 ms upplever användaren det som omedelbart. Mikrointeraktioner täcker den luckan genom att animera knappens tillstånd innan serversvaret kommer tillbaka. Optimistic UI går ett steg längre: den animerar inte bara väntan, den antar på förhand att aktionen kommer att lyckas, och visar direkt slutresultatet – hjärtat i "Gilla"-knappen fylls i omedelbart, räknaren ökar med ett, trots att förfrågan till servern precis har startat. Gränssnittet säger något till användaren som ännu inte är sant – och antar att det snart kommer att bli det.
Det fungerar utmärkt så länge antagandet stämmer – och i praktiken, för aktioner som "gilla", "lägg till som favorit" eller "markera som läst", stämmer det i den överväldigande majoriteten av fallen. Problemet börjar där de flesta implementationer försöker göra det manuellt: setState vid klick, catch med en omvänd setState vid fel. Det ser oskyldigt ut i ett isolerat test med ett enda klick. Det spårar ur när användaren klickar på knappen två gånger snabbt efter varandra – exakt samma fjärde element ur Saffers modell (loopar och lägen) som i artikeln om mikrointeraktioner, fast den här gången är felet inte kosmetiskt utan leder till ett verkligt inkonsekvent tillstånd.
Varför en manuell rollback spårar ur vid två klick
Föreställ dig den mest uppenbara, manuella implementationen: ett klick växlar direkt liked i det lokala tillståndet, skickar en förfrågan, och i catch återställs liked till värdet före klicket, som sparades i en variabel innan förfrågan skickades.
jsx
// Naiv rollback – fungerar för ett klick, går sönder vid tvåconst handleClick = async () => {const previousLiked = liked; // fryst ögonblicksbild av state FÖRE klicketsetLiked(!liked);try {await toggleLike(postId, !liked);} catch {setLiked(previousLiked); // återställer till state före DETTA klick}};
Scenariot som förstör det: användaren klickar "gilla" (förfrågan A startar, previousLiked = false), efter 100 ms klickar "ogilla" (förfrågan B startar, previousLiked = true, eftersom det lokala tillståndet redan hunnit ändras till true). Om förfrågan A misslyckas medan förfrågan B fortfarande pågår eller redan lyckats, återställer catch för förfrågan A tillståndet till false – och skriver därmed över effekten av klick B, som användaren gjorde medvetet och som kan ha lyckats. Rollbacken för en förfrågan raderade effekten av en annan, eftersom båda opererade på samma delade tillståndsvariabel, utan någon kännedom om varandra. Det är exakt den typen av fel som inte visar sig i ett snabbt manuellt test – det visar sig i produktion, när en verklig användare klickar snabbare än utvecklaren föreställde sig när koden skrevs.
Hur useOptimistic undviker problemet strukturellt, inte med en lapp
useOptimistic i React är inte en bekvämare inpackning av setState plus try/catch. Det är en annan konceptuell modell: i stället för att hålla ett enda, muterbart tillståndsfält som du manuellt växlar och manuellt återställer, beräknar useOptimistic ett tillfälligt overlay löpande, utifrån det aktuella verkliga tillståndet, synligt bara under den aktuella operationens (transitionens) varaktighet. När operationen är klar – oavsett om den lyckas eller misslyckas – försvinner overlayet av sig självt och avslöjar det som just då är det verkliga tillståndet. Du skriver ingen rollback-kod. Rollbacken är en naturlig konsekvens av att det tillfälliga overlayet försvinner, inte en separat kodväg som du kan glömma att hantera eller hantera fel.
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);// Utan manuell rollback i catch – om aktionen kastar ett fel,// avvisar React ändå overlayet när transitionen är klar,// vilket avslöjar det verkliga "liked" som skickats i props.});};return (<buttontype="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 avgörande skillnaden mot den manuella versionen: overlayet som useOptimistic beräknar baseras på state som skickas som första argument – alltså på det aktuella, verkliga tillståndet från props (vanligtvis från en Server Action och revalidatePath, som i artikeln om Server Actions) – och inte på en separat, fryst ögonblicksbild sparad manuellt i en lokal variabel. När användaren klickar två gånger i rad beräknar varje anrop till setOptimistic ett nytt overlay utifrån det aktuella tillståndet, så det andra klicket kan inte av misstag skrivas över av en försenad rollback från det första – det finns ingen separat "återställ till det sparade värdet"-väg som kan ta fel på exakt vilket ögonblick i tiden det gällde.
När optimistic UI är en bra idé, och när det är en farlig lögn
Inte alla aktioner passar för det här mönstret, och det är inte en smaksak, utan en fråga om konsekvenserna av ett felaktigt antagande. "Gilla", "lägg till som favorit", "markera som läst" – billiga i konsekvenser, lätta att ångra, lyckas nästan alltid. Betalning, oåterkallelig kontoborttagning, att skicka ett meddelande till en annan person – där är det riskabelt att optimistiskt visa framgång innan den faktiskt inträffat: användaren fattar följande beslut utifrån information som kan visa sig vara falsk, och att dra tillbaka en sådan "lögn" några sekunder senare är betydligt mer förvirrande än en vanlig laddningsindikator som bara tar lite längre tid.
Även där optimistic UI är motiverat återskapar en tyst rollback utan någon information exakt det perceptionsproblem den skulle lösa. Användaren såg det ifyllda hjärtat, ansåg saken avklarad, riktade uppmärksamheten någon annanstans – och en stund senare återgår hjärtat tyst till sitt tomma tillstånd, utan förklaring. Det är samma fel som "avsaknad av loopar och lägen" i Saffers modell, fast förskjutet i tiden: det fanns feedback vid framgång, men ingen feedback vid misslyckande. Lösningen är samma verktyg som beskrevs i artikeln om tillgänglighet – aria-live="polite" eller role="alert" vid felmeddelandet, så att återställningen av tillståndet inte blir tyst, varken visuellt eller ljudmässigt, för någon som just då inte tittar exakt på den knappen i det ögonblick den återställs.
Fallgropar och god praxis
setOptimisticmåste anropas inuti en transition. UtanförstartTransition(eller en action som skickas tilluseActionState/<form action>) fungeraruseOptimisticinte som förväntat – mekanismen är oupplösligt kopplad till livscykeln för en specifik transition, inte till ett godtyckligt ögonblick under rendering.- Uppdateringsfunktionen som skickas till
useOptimisticmåste vara ren och billig. Den körs synkront under renderingen för att beräkna overlayet – komplexa beräkningar eller sidoeffekter här är ett kategorifel, inte bara en prestandafråga. - Dölj inte misslyckanden i tystnad. En tyst rollback utan meddelande är värre än att inte ha en optimistisk uppdatering alls – användaren får en falsk bekräftelse, och sedan ingen information om att något ändå gick fel.
- Reservera mönstret för billiga och reversibla aktioner. Där kostnaden för ett felaktigt antagande är hög (betalningar, oåterkalleliga operationer) är ett vanligt laddningstillstånd med tydlig bekräftelse i efterhand ett bättre val, inte ett gränssnitt som gissar i förväg.
Sammanfattning
Optimistic UI är inget animationstrick – det är ett medvetet antagande att en aktion kommer att lyckas, visat för användaren innan servern bekräftat det. En manuell implementation med setState och catch fungerar tills någon klickar snabbare än utvecklaren förutsåg – då kan rollbacken från en operation skriva över resultatet av en annan, eftersom båda delar samma frysta ögonblicksbild av tillståndet. useOptimistic tar bort den felklassen strukturellt: overlayet beräknas alltid utifrån det aktuella, verkliga tillståndet, inte utifrån ett separat sparat värde, så rollbacken är inte kod du skriver och kan skriva fel – den är en naturlig konsekvens av att det tillfälliga antagandet försvinner. Kvar blir ett enda beslut som inget API kan fatta åt dig: om en given aktion är tillräckligt billig och reversibel för att det överhuvudtaget ska vara meningsfullt att ljuga om den, ens för ett ögonblick.