React 19 introduserer en ny hook kalt useOptimistic, som skal gjøre optimitiske oppdateringer enklere.
I påvente av den, ønsker jeg å vise hva optimistiske oppdateringer er, og hvordan du kan implementere det i løsningen din allerede nå, uten å ty til en eksperimentell React-versjon.
I denne posten viser jeg optimistiske oppdateringer med vanilla React og TanStack Query.
Men først — hva er optimistiske oppdateringer?
Som bruker kan du bli usikker på om noe foregår om du trykker på en knapp, men ingenting skjer.
I eksempelet under sender jeg en melding, men det tar en god stund før jeg får se resultatet av handlingen:
Om du forventer at handlingen har suksess flertallet av gangene, kan du gi brukeren en umiddelbar tilbakemelding, før backend-kallet er fullført, altså en optimistisk oppdatering:
Optimistisk oppdatering i tre steg
Selv om ulike biblioteker kan ha ulik syntaks, har optimistiske oppdateringer likevel et mønster som går igjen. Etter at brukeren har gjort en handling, som å sende en melding, er det tre steg:
- Klient-tilstanden endres umiddelbart, for å vise endring til brukeren. Husk også gammel tilstand, tilfelle feil skjer og tilstand rulles tilbake
- Et kall gjøres til serveren for å lagre tilstand til server
- Etter API-kallet fullføres: a) Hvis kallet var vellykket, oppdater klient-tilstanden med den nye server-tilstanden. b) Hvis kallet feiler, rull tilbake klient-tilstanden til gammel tilstand
Vi skal nå se på hvordan implementere dette i vanilla React og TanStack Query.
– Jeg har misforstått React Query, og det har nok du også!
Optimistisk oppdatering i vanilla React
Før vi ser på hvordan vi implementerer optimistisk oppdatering i TanStack Query, kan det være nyttig å se hvordan du kan gjøre det i vanilla React.
Under har jeg implementert meldingslisten. Først litt setup, hvor vi starter med noen meldinger og lager et fake API-kall:
// 👇 Setter opp fake tilstand for backend-end kall
const initialMessages = [
{ id: "1", content: "Hei! Hvordan går det?" },
{ id: "2", content: "Alt bra her, hva med deg?" },
{ id: "3", content: "Veldig bra, takk for at du spurte!" },
];
const fakeNewMessage = {
id: "4",
content: "Dette er en ny melding!",
};
interface Message {
id: string;
content: string;
}
const fakeApiCall = (newMessage: Message): Promise<Message[]> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.1) {
resolve([...initialMessages, fakeNewMessage]);
} else {
reject("Feil ved lagring av melding!");
}
}, 1000);
});
};
Så har vi meldings-komponenten, nå først uten optimistisk oppdatering. Her rendrer vi en enkel liste av meldinger. Så ved klikk på knappen, vil en hardkodet melding legges til i listen av meldinger:
export const MessageList = () => {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [loading, setLoading] = useState(false);
const addMessage = async (newMessage: Message) => {
setLoading(true);
try {
const messagesFromBackend = await fakeApiCall(newMessage);
setMessages(messagesFromBackend);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
function handleAddMessage() {
void addMessage({
id: "temp-id",
content: "Dette er en ny melding!",
});
}
return (
<div>
<h2>Meldinger</h2>
<ul>
{messages.map((message) => (
<li
key={message.id}
>
{message.content}
</li>
))}
</ul>
<button onClick={handleAddMessage} disabled={loading}>
Legg til ny melding
</button>
</div>
);
};
I kodesnutten under har jeg lagt til optimistisk oppdatering. Forskjellen er å legge til den nye meldingen i klient-tilstanden med en gang.
Så vil meldingen lagres i backend, og vi oppdaterer tilstanden i klienten med data fra serveren. Ved feil ruller vi tilbake til den forrige tilstanden:
export const MessageList = () => {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [loading, setLoading] = useState(false);
const addMessageOptimistically = async (newMessage: Message) => {
// 👇 1a: Lagre forrige tilstand
const prevMessages = messages;
const optimisticMessages = [...prevMessages, newMessage];
// 👇 1: Setter state med ny melding med en gang
setMessages(optimisticMessages);
setLoading(true);
try {
// 👇 2: Lagre melding
const messagesFromBackend = await fakeApiCall();
// 👇 3a: Setter tilstand med faktiske data
setMessages(messagesFromBackend);
} catch (error) {
console.error(error);
// 👇 3b: Resetter tilstand ved feil
setMessages(prevMessages);
} finally {
setLoading(false);
}
};
function handleAddMessage() {
void addMessageOptimistically({
id: "temp-id", // 👈 Setter en gjenkjennelig id, så jeg kan style melding
content: "Dette er en ny melding!",
});
}
return (
<div>
<h2>Meldinger</h2>
<ul>
{messages.map((message) => (
<li
key={message.id}
/* 👇 Ny melding er transparent */
style={{ opacity: message.id === "temp-id" ? 0.3 : 1 }}
>
{message.content}
</li>
))}
</ul>
<button onClick={handleAddMessage} disabled={loading}>
Legg til ny melding
</button>
</div>
);
};
TanStack Query
TanStack Query er et populært bibliotek som forenkler datahenting. Også i dette biblioteket kan du gjøre optimistiske oppdateringer, og det følger samme mønster vi har sett på.
La oss igjen først se på eksempelet uten optimistisk oppdatering.
I TanStack Query er det ikke en komponents state du skal oppdatere, men en cache som deles på tvers i applikasjonen. Her henter vi data med en useQuery og lagrer det på cache-nøkkelen “messages”. Vi bruker useMutation til å sende en melding, og etter suksess invaliderer vi cachen, så ny data oppdateres:
export const MessageList = () => {
const queryClient = useQueryClient();
const { data: messages } = useQuery(["messages"], fetchMessages);
const mutation = useMutation(fakeApiCall, {
onSuccess: async () => {
await queryClient.invalidateQueries(["messages"]);
},
});
const handleAddMessage = () => {
const newMessage: Message = {
id: "temp-id",
content: "Dette er en ny melding!",
};
mutation.mutate(newMessage);
};
return (...
La oss nå se på et eksempel med optimistisk oppdatering.
Denne gangen har vi en funksjon “onMutate”, som setter ny tilstand umiddelbart. Vi har også lagt til feilhåndtering med “onError”, hvor tidligere tilstand settes ved feil. Invalideringen av cachen er flyttet fra “onSuccess” til “onSettled”, som betyr at invalideringen skjer både etter feil og etter suksess, så vi til slutt ender opp med lik tilstand i klienten og serveren:
export const MessageList = () => {
const queryClient = useQueryClient();
const { data: messages } = useQuery(["messages"], fetchMessages);
const mutation = useMutation(postMessage, {
// 👇 Ved mutering, gjør følgende:
onMutate: async (newMessage: Message) => {
// 👇 Stopp eventuelle pågående queries, for å unngå race condition
await queryClient.cancelQueries(["messages"]);
// 👇 1a: Lagre forrige tilstand
const previousMessages = queryClient.getQueryData<Message[]>([
"messages",
]);
// 👇 1: Setter tilstand med ny melding med en gang
queryClient.setQueryData<Message[]>(["messages"], (old = []) => [
...old,
newMessage,
]);
return { previousMessages };
},
onError: (err, newMessage, context) => {
// 👇 3b: Resetter tilstand ved feil
queryClient.setQueryData(["messages"], context?.previousMessages);
},
onSettled: async () => {
// 👇 3a/3b: Ved feil eller suksess, invalider cache for å hente ferske data
await queryClient.invalidateQueries(["messages"]);
},
});
const handleAddMessage = () => {
const newMessage: Message = {
id: "temp-id",
content: "Dette er en ny melding!",
};
mutation.mutate(newMessage);
};
return (...
Teknologiene norske utviklere bruker: – Mildt sagt ikke fornøyd!
Videre lesing
For å lære mer om optimistiske oppdateringer i TanStack Query, ta en titt på docs. Der kan du også se en enklere syntaks til optimistisk oppdatering om muteringen skjer i samme komponent som dataene skal vises. Da kan du aksessere dataene (se variables) du sender med muteringen, og på den måten forhåndsvise endringene.
For å se hvordan optimistiske oppdateringer skjer med den nye useOptimistic-hooken, kan jeg anbefale Jack Herringtons video på temaet.
Han viser også hvordan hooken kan brukes med TanStack Query, men jeg er usikker på om den forenkler noe, eller om eksempelet som vist over er bedre. Hva tror du?