reactanimaatiot

Optimistic UI Reactissa: käyttöliittymä, joka valehtelee tietoisesti

Optimistic UI Reactissa: käyttöliittymä, joka valehtelee tietoisesti

Kirjoitin mikrointeraktioartikkelissa Dohertyn kynnysarvosta – jos järjestelmä vastaa alle 400 millisekunnissa, käyttäjä kokee sen välittömäksi. Mikrointeraktiot tukkivat tämän aukon animoimalla painikkeen tilaa, ennen kuin palvelimen vastaus ehtii palata. Optimistic UI menee askeleen pidemmälle: se ei pelkästään animoi odotusta, vaan olettaa etukäteen, että toiminto onnistuu, ja näyttää lopputuloksen heti – sydän "Tykkää"-painikkeessa täyttyy välittömästi, laskuri kasvaa yhdellä, vaikka pyyntö palvelimelle on juuri käynnistynyt. Käyttöliittymä kertoo käyttäjälle jotain, mikä ei vielä ole totta – ja olettaa, että se on kohta.

Tämä toimii täydellisesti, kunhan oletus pitää paikkansa – ja käytännössä, "tykkää"-, "lisää suosikkeihin"- tai "merkitse luetuksi" -tyyppisten toimintojen kohdalla se pitää paikkansa valtaosassa tapauksista. Ongelma alkaa siitä, kun useimmat toteutukset yrittävät tehdä tämän käsin: setState klikkauksessa, catch käänteisellä setState-kutsulla virheen sattuessa. Tämä näyttää viattomalta eristetyssä testissä yhdellä klikkauksella. Se hajoaa, kun käyttäjä klikkaa painiketta kahdesti nopeasti peräkkäin – täsmälleen sama Safferin mallin neljäs elementti (silmukat ja tilat) mikrointeraktioartikkelista, paitsi että tällä kertaa virhe ei ole kosmeettinen vaan johtaa todella epäjohdonmukaiseen tilaan.

Miksi käsin tehty rollback hajoaa kahden klikkauksen kohdalla

Kuvittele ilmeisin, käsin tehty toteutus: klikkaus vaihtaa heti liked-arvon paikallisessa tilassa, lähettää pyynnön, ja catch-lohkossa palauttaa liked-arvon takaisin klikkausta edeltäneeseen arvoon, joka tallennettiin muuttujaan ennen pyynnön lähettämistä.

jsx

// Naiivi rollback – toimii yhdelle klikkaukselle, hajoaa kahdella
const handleClick = async () => {
const previousLiked = liked; // jäädytetty tilannekuva tilasta ENNEN klikkausta
setLiked(!liked);
try {
await toggleLike(postId, !liked);
} catch {
setLiked(previousLiked); // palauttaa tilan TÄTÄ klikkausta edeltäneeksi
}
};

Skenaario, joka rikkoo tämän: käyttäjä klikkaa "tykkää" (pyyntö A käynnistyy, previousLiked = false), 100 ms myöhemmin klikkaa "peru tykkäys" (pyyntö B käynnistyy, previousLiked = true, koska paikallinen tila on jo ehtinyt muuttua arvoon true). Jos pyyntö A epäonnistuu ja pyyntö B on juuri käynnissä tai on jo onnistunut, pyynnön A catch-lohko palauttaa tilan arvoon false – ylikirjoittaen klikkauksen B vaikutuksen, jonka käyttäjä teki tietoisesti ja joka on saattanut jo onnistua. Yhden pyynnön rollback pyyhki toisen pyynnön vaikutuksen, koska molemmat operoivat samalla, jaetulla tilamuuttujalla, tietämättä mitään toisistaan. Tämä on juuri sellainen virhe, joka ei tule esiin nopeassa manuaalisessa testissä – se tulee esiin tuotannossa, kun oikea käyttäjä klikkaa nopeammin kuin kehittäjä osasi kuvitella koodia kirjoittaessaan.

Miten useOptimistic välttää tämän ongelman rakenteellisesti, ei paikkauksella

Reactin useOptimistic ei ole mukavampi kääre setState- ja try/catch-yhdistelmälle. Se on eri käsitteellinen malli: sen sijaan, että pidettäisiin yhtä muutettavaa tilakenttää, jota käsin vaihdetaan ja käsin perutaan, useOptimistic laskee väliaikaisen peittokerroksen lennossa, nykyisen todellisen tilan pohjalta, ja se on näkyvissä vain kyseisen operaation (transition) keston ajan. Kun operaatio päättyy – onnistuen tai virheeseen – peittokerros katoaa itsestään, paljastaen sen, mikä sillä hetkellä on todellinen tila. Et kirjoita rollback-koodia. Rollback on luonnollinen seuraus väliaikaisen peittokerroksen katoamisesta, ei erillinen koodipolku, jonka voi unohtaa käsitellä tai käsitellä väärin.

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);
// Ilman käsin kirjoitettua rollbackia catch-lohkossa – jos aktio
// heittää virheen, React silti hylkää peittokerroksen transitionin
// päätyttyä, paljastaen propseina välitetyn todellisen "liked"-arvon.
});
};
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;

Keskeinen ero käsin tehtyyn versioon: useOptimistic-hookin laskema peittokerros perustuu state-arvoon, joka on välitetty ensimmäisenä argumenttina – eli nykyiseen, todelliseen tilaan propseista (yleensä peräisin Server Actionista ja revalidatePath-kutsusta, kuten Server Actions -artikkelissa) – ei erilliseen, käsin paikalliseen muuttujaan tallennettuun jäädytettyyn tilannekuvaan. Kun käyttäjä klikkaa kahdesti peräkkäin, jokainen setOptimistic-kutsu laskee uuden peittokerroksen suhteessa nykyiseen tilaan, joten toista klikkausta ei voi vahingossa ylikirjoittaa ensimmäisen myöhästynyt rollback – ei ole olemassa erillistä "palauta tallennettuun arvoon" -polkua, joka voisi sekoittaa, mistä täsmällisestä ajanhetkestä oli kyse.

Milloin optimistic UI on hyvä idea ja milloin se on vaarallinen valhe

Kaikki toiminnot eivät sovi tähän kaavaan, eikä kyse ole makuasiasta vaan väärän oletuksen seurauksista. "Tykkää", "lisää suosikkeihin", "merkitse luetuksi" – seurauksiltaan halpoja, helposti peruutettavia, onnistuvat lähes aina. Maksu, tilin peruuttamaton poistaminen, viestin lähettäminen toiselle henkilölle – näissä onnistumisen optimistinen näyttäminen ennen kuin se on tosiasiassa tapahtunut on riskialtista: käyttäjä tekee seuraavia päätöksiä tiedon perusteella, joka saattaa osoittautua vääräksi, ja tällaisen "valheen" peruminen muutama sekunti myöhemmin on paljon hämmentävämpää kuin tavallinen latausikoni, joka vain kestää hetken pidempään.

Jopa siellä, missä optimistic UI on järkevä, hiljainen rollback ilman minkäänlaista tietoa toistaa täsmälleen sen havaintoon liittyvän ongelman, jota sen piti ratkaista. Käyttäjä näki sydämen täyttyneen, piti asiaa hoidettuna, siirsi huomionsa muualle – ja hetken kuluttua sydän palaa hiljaa tyhjäksi, ilman selitystä. Tämä on sama "silmukoiden ja tilojen puuttumisen" virhe Safferin mallista, vain ajassa siirrettynä: palaute onnistumisesta oli olemassa, mutta palautetta epäonnistumisesta ei. Ratkaisu on sama työkalu, jota kuvasin saavutettavuusartikkelissa – aria-live="polite" tai role="alert" epäonnistumisviestissä, jotta tilan peruminen ei jää hiljaiseksi visuaalisesti eikä äänellisesti kenellekään, joka ei juuri sillä hetkellä katso tarkasti kyseistä painiketta, kun se peruuntuu.

Sudenkuopat ja hyvät käytännöt

  • setOptimistic täytyy kutsua transitionin sisällä. startTransition-kutsun ulkopuolella (tai useActionState/<form action>-elementille välitetyn aktion ulkopuolella) useOptimistic ei toimi odotetusti – mekanismi on erottamattomasti sidottu tietyn transitionin elinkaareen, ei mihin tahansa renderöintihetkeen.
  • useOptimistic-hookille välitetyn päivitysfunktion täytyy olla puhdas ja kevyt. Se suoritetaan synkronisesti renderöinnin aikana peittokerroksen laskemiseksi – monimutkaiset laskelmat tai sivuvaikutukset tässä kohdassa ovat kategorinen virhe, eivät vain suorituskykykysymys.
  • Älä piilota epäonnistumista hiljaisuudessa. Hiljainen rollback ilman viestiä on pahempi kuin optimistisen päivityksen puuttuminen kokonaan – käyttäjä saa väärän vahvistuksen ja sen jälkeen ei mitään tietoa siitä, että jokin meni pieleen.
  • Varaa kaava halpoihin ja peruutettavissa oleviin toimintoihin. Siellä, missä väärän oletuksen hinta on korkea (maksut, peruuttamattomat toiminnot), parempi valinta on tavallinen lataustila selkeällä vahvistuksella jälkikäteen, ei käyttöliittymä, joka arvaa etukäteen.

Yhteenveto

Optimistic UI ei ole animaatiotemppu – se on tietoinen oletus siitä, että toiminto onnistuu, näytettynä käyttäjälle ennen kuin palvelin sen vahvistaa. Käsin tehty toteutus setState- ja catch-yhdistelmällä toimii siihen asti, kunnes joku klikkaa nopeammin kuin kehittäjä osasi ennakoida – silloin yhden operaation rollback voi ylikirjoittaa toisen vaikutuksen, koska molemmat jakavat saman jäädytetyn tilannekuvan. useOptimistic poistaa tämän virheluokan rakenteellisesti: peittokerros lasketaan aina suhteessa nykyiseen, todelliseen tilaan, ei erikseen tallennettuun arvoon, joten rollback ei ole koodia, jonka kirjoitat ja voit kirjoittaa väärin – se on luonnollinen seuraus väliaikaisen oletuksen katoamisesta. Jäljelle jää yksi päätös, jota mikään API ei tee puolestasi: onko kyseinen toiminto tarpeeksi halpa ja peruutettavissa, jotta siitä ylipäätään kannattaa valehdella, edes hetkeksi.