Derfor bør du bruke optimistiske oppdateringer – og slik gjør du det

Med TanStack Query er det enkelt å gi brukerne en bedre opplevelse.

Mens du venter på useOptimistic-hooken i React 19, kan du få til optimistiske oppdateringer av UI-et ved hjelp av TanStack Query. 📸: Bekk
Mens du venter på useOptimistic-hooken i React 19, kan du få til optimistiske oppdateringer av UI-et ved hjelp av TanStack Query. 📸: Bekk Vis mer

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:

Bruker trykker på “melding”, og UI vises etter backend-kall har respondert.

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:

Bruker trykker på “melding”, og ny UI vises umiddelbart, men transparent. Etter backend-kall har respondert, vises ny melding som vanlig

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:

  1. Klient-tilstanden endres umiddelbart, for å vise endring til brukeren. Husk også gammel tilstand, tilfelle feil skjer og tilstand rulles tilbake
  2. Et kall gjøres til serveren for å lagre tilstand til server
  3. 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.

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 (...

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?