Optimistic UI in React: een interface die bewust liegt
Optimistic UI in React: een interface die bewust liegt
In het artikel over micro-interacties schreef ik over de Doherty-drempel – als een systeem binnen 400 ms reageert, ervaart de gebruiker het als onmiddellijk. Micro-interacties dichten dat gat met een animatie van de knopstatus, nog voordat het antwoord van de server terug is. Optimistic UI gaat een stap verder: het animeert niet alleen het wachten, het gaat er van tevoren van uit dat de actie zal slagen, en toont meteen het eindresultaat – het hart in de knop "Vind ik leuk" vult zich onmiddellijk, de teller loopt met één op, terwijl de request naar de server nog maar net is gestart. De interface vertelt de gebruiker iets wat nog niet waar is – en gaat ervan uit dat het dat zo meteen wel zal zijn.
Dat werkt uitstekend, zolang de aanname klopt – en in de praktijk klopt die, voor acties als "vind ik leuk", "aan favorieten toevoegen" of "als gelezen markeren", in de overgrote meerderheid van de gevallen. Het probleem begint waar de meeste implementaties dit handmatig proberen: setState bij een klik, catch met een omgekeerde setState bij een fout. Dat oogt onschuldig in een geïsoleerde test met één klik. Het loopt fout zodra een gebruiker de knop twee keer snel na elkaar aanklikt – exact hetzelfde vierde element van het model van Saffer (loops en modes) uit het artikel over micro-interacties, alleen leidt de fout dit keer niet tot iets cosmetisch, maar tot een werkelijk inconsistente status.
Waarom een handmatige rollback fout loopt bij twee klikken
Stel je de meest voor de hand liggende, handmatige implementatie voor: een klik schakelt onmiddellijk liked om in de lokale status, stuurt een request, en in de catch wordt liked teruggezet naar de waarde van vóór de klik, die in een variabele was onthouden voordat de request werd verstuurd.
jsx
// Naïeve rollback – werkt bij één klik, loopt mis bij tweeconst handleClick = async () => {const previousLiked = liked; // bevroren momentopname van de status VÓÓR de kliksetLiked(!liked);try {await toggleLike(postId, !liked);} catch {setLiked(previousLiked); // gaat terug naar de status van vóór DEZE klik}};
Het scenario dat dit fout laat lopen: de gebruiker klikt op "vind ik leuk" (start van request A, previousLiked = false), en klikt na 100 ms op "vind ik niet meer leuk" (start van request B, previousLiked = true, omdat de lokale status al is veranderd naar true). Als request A faalt terwijl request B nog loopt of al is geslaagd, zet de catch van request A de status terug naar false – en overschrijft daarmee het effect van klik B, die de gebruiker bewust heeft uitgevoerd en die mogelijk al geslaagd is. De rollback van de ene request heeft het resultaat van de andere teniet gedaan, omdat beide dezelfde, gedeelde statusvariabele beïnvloedden, zonder enige kennis van elkaar. Dit is precies het soort fout dat niet naar boven komt bij een snelle handmatige test – het komt naar boven in productie, wanneer een echte gebruiker sneller klikt dan de ontwikkelaar zich tijdens het schrijven van de code had voorgesteld.
Hoe useOptimistic dit probleem structureel vermijdt, niet met een pleister
useOptimistic in React is geen handigere wrapper om setState plus try/catch. Het is een ander conceptueel model: in plaats van één, muteerbaar statusveld bij te houden dat je handmatig omschakelt en handmatig terugdraait, berekent useOptimistic doorlopend een tijdelijke overlay op basis van de actuele, echte status, zichtbaar alleen zolang een specifieke operatie (transition) loopt. Zodra de operatie eindigt – met succes of met een fout – verdwijnt de overlay vanzelf, en komt tevoorschijn wat op dat moment de werkelijke status is. Je schrijft geen rollback-code. Rollback is een natuurlijk gevolg van het verdwijnen van de tijdelijke overlay, geen aparte codepad die je kunt vergeten af te handelen of verkeerd kunt afhandelen.
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);// Geen handmatige rollback in een catch nodig – als de actie een fout// gooit, verwerpt React de overlay toch zodra de transition eindigt,// en komt de echte "liked" uit de props weer tevoorschijn.});};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;
Het cruciale verschil met de handmatige versie: de overlay die useOptimistic berekent, is gebaseerd op de state die als eerste argument is meegegeven – dus op de actuele, echte status uit de props (meestal afkomstig van een Server Action en revalidatePath, zoals in het artikel over Server Actions) – en niet op een aparte, bevroren momentopname die handmatig in een lokale variabele is bewaard. Wanneer de gebruiker twee keer achter elkaar klikt, berekent elke aanroep van setOptimistic een nieuwe overlay ten opzichte van de huidige status, dus kan de tweede klik niet per ongeluk worden overschreven door een te laat komende rollback van de eerste – er bestaat geen apart pad van "ga terug naar de onthouden waarde" dat zich zou kunnen vergissen over welk exact moment in de tijd bedoeld werd.
Wanneer optimistic UI een goed idee is, en wanneer het een gevaarlijke leugen is
Niet elke actie leent zich voor dit patroon, en dat is geen kwestie van smaak, maar van de consequenties van een verkeerde aanname. "Vind ik leuk", "aan favorieten toevoegen", "als gelezen markeren" – goedkoop in de gevolgen, makkelijk terug te draaien, slagen bijna altijd. Een betaling, het onherroepelijk verwijderen van een account, een bericht versturen naar iemand anders – daar is optimistisch een succes tonen voordat het daadwerkelijk zover is, riskant: de gebruiker neemt vervolgens beslissingen op basis van informatie die onwaar kan blijken te zijn, en het terugdraaien van zo'n "leugen" enkele seconden later is veel verwarrender dan een gewone spinner die simpelweg iets langer duurt.
Zelfs waar optimistic UI zinvol is, herhaalt een stille rollback zonder enige melding precies het perceptieprobleem dat het probeerde op te lossen. De gebruiker zag een gevuld hart, beschouwde de zaak als afgehandeld, verlegde de aandacht ergens anders naartoe – en even later keert het hart stilletjes terug naar de lege staat, zonder uitleg. Dat is dezelfde fout van "ontbrekende loops en modes" uit het model van Saffer, alleen in de tijd verschoven: feedback bij succes was er wel, feedback bij falen niet meer. De oplossing is hetzelfde instrument dat ik beschreef in het artikel over toegankelijkheid – aria-live="polite" of role="alert" bij het foutbericht, zodat het terugdraaien van de status noch visueel, noch akoestisch stil verloopt voor wie op dat moment niet precies naar die knop kijkt.
Valkuilen en goede praktijken
setOptimisticmoet binnen een transition worden aangeroepen. BuitenstartTransition(of een actie die is doorgegeven aanuseActionState/<form action>) werktuseOptimisticniet zoals verwacht – het mechanisme is onlosmakelijk verbonden met de levenscyclus van een specifieke transition, niet met een willekeurig moment tijdens het renderen.- De update-functie die je aan
useOptimisticmeegeeft moet zuiver en goedkoop zijn. Ze wordt synchroon uitgevoerd tijdens het renderen om de overlay te berekenen – complexe berekeningen of side effects op die plek zijn een fout van een andere categorie, niet slechts een performancekwestie. - Verberg een mislukking niet stilzwijgend. Een stille rollback zonder melding is erger dan helemaal geen optimistische update – de gebruiker krijgt een valse bevestiging, en daarna geen enkele indicatie dat er toch iets is misgegaan.
- Reserveer het patroon voor goedkope, omkeerbare acties. Waar de kost van een verkeerde aanname hoog is (betalingen, onherroepelijke handelingen), is een gewone laadstatus met een duidelijke bevestiging achteraf de betere keuze, geen interface die vooruit gokt.
Samenvatting
Optimistic UI is geen animatietruc – het is de bewuste aanname dat een actie zal slagen, getoond aan de gebruiker voordat de server dat heeft bevestigd. Een handmatige implementatie via setState en catch werkt totdat iemand sneller klikt dan de ontwikkelaar had voorzien – dan kan de rollback van de ene operatie het resultaat van de andere overschrijven, omdat beide dezelfde, bevroren momentopname van de status delen. useOptimistic verwijdert deze klasse fouten structureel: de overlay wordt altijd berekend ten opzichte van de actuele, echte status, niet ten opzichte van een apart onthouden waarde, dus is de rollback geen code die je schrijft en verkeerd kunt schrijven – het is een natuurlijk gevolg van het verdwijnen van een tijdelijke aanname. Er blijft één beslissing over die geen enkele API voor je zal nemen: of een bepaalde actie goedkoop en omkeerbaar genoeg is om er, al is het maar even, over te liegen.