Hopp til hovedinnhold

<ForrigeUke uke="44" år="2025" />

Publisert:4. november
Skrevet av:Marcus Haaland

Alt om overganger og hvorfor fremtidens React er async — i denne ukas ForrigeUke.

Dette var uka for pai og imperativ programmering — og 3713 ting skjedde i frontendverdenen!

«<ForrigeUke /> er en artikkelserie som oppsummerer hva som skjedde i frontend-verden i uka som var.»

Jeg har lenge slitt med å forstå hva funksjoner som useTransition, useDeferredValue og ikke minst hva use() gjør. Sistnevnte har jo et helt tullete navn — men forrige uke forstod jeg endelig hva den var godt for.

Jack Herrington slapp en ti minutters video som oppsummerer hva use gjør — og hvordan det henger sammen med Suspense, Transitions og ViewTransitions. Dette er verktøy for å støtte "Async React". Kort sagt innebærer det å la arbeid som å laste inn et element skje på lavprioritet, så andre viktige ting som brukerinput blir prioritert og gjennomført raskere.

Motivasjonen min for å dykke ordentlig ned i transitions kom etter Ricky Hanlons foredrag "Async React" på React Conf (video del 1, slutter før demo. Video del 2, starter demo). Han snakket om hvordan vi bør tenke på React som asynkront som standard, selv for funksjoner som egentlig ikke er asynkrone.

Ricky Hanlon holder foredrag om transitions
Hanlon sier transitions koordinerer mellom de tre viktige laste-tilstandene: opptatt, lasting og ferdig.

Dette implementeres ved å bruke transitions for å koordinere arbeid.

For en treg enhet, vil transitions hjelpe å gi respons når brukeren trykker, venter på svar eller får en fin overgang til nytt innhold. Mens på en rask enhet vil appen fortsatt føles umiddelbar.

Med nyvunnet motivasjon gikk jeg løs på å lære meg disse nye funksjonene. Selvfølgelig via en dårlig forkledd todo-app.

Hva gjør use()?

Appen jeg lekte med så slik ut, hvor jeg kan huke av jeg du har gjennomført noen ukomfortable aktiviteter:

Enkel todo-app: Søk og toggling av aktiviteter

Før jeg introduserer use, ta en titt på hvordan vi kan gjøre datahenting uten et bibliotek for datahenting — med useEffect:

// ActivitiesPage

export function ActivitiesPage() {
  const [activities, setActivities] = useState<Activity[]>([]);
  // 👇 Holder rede på laste-tilstand
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 👇 Ved mount henter vi data
    const initialFetch = async () => {
      setLoading(true);
      // 👇 Setter responsdata til useState
      const response = await fetchActivities();
      setActivities(response);
      setLoading(false);
    };
    initialFetch();
  }, []);
  
  ...
	  
	return (
	// 👇 Betinget viser fallback med loading- tilstanden
	{loading ? (
	  <ActivityListFallback />
	) : (
	  <ListActivities
	    activities={activities}
	    onToggle={onToggle}
	  />
	)}
  )
  ...

Med useEffect henter vi data, setter loading-tilstand manuelt, og setter responsdataene til useState.

Med use()-funksjonen ser ting litt annerledes ut:

// ActivitiesPage

export function ActivitiesPage() {
  // 👇 state holder på promise - ikke responsdata
  const [activitiesPromise, setActivitiesPromise] = useState<
    Promise<Activity[]>
  >(() => fetchActivities());
  
  ...
  
  return (
    /*
      👇 Istedenfor manuell loading-tilstand, 
      vil fallback vises så lenge promise ikke er resolved
    */
    <Suspense fallback={<ActivityListFallback />}>
      <ListActivities
        activitiesPromise={activitiesPromise}
        onToggle={onToggle}
      />
    </Suspense>
  )

// ListActivities

function ListActivities({ activitiesPromise, onToggle }: Props) {
  // 👇 Promise resolves i child
  const activities = use(activitiesPromise);
  
  ...

Her var det litt av hvert å pakke ut.

For å starte med slutten, use-funksjonen:

// ListActivities

const activities = use(activitiesPromise);

use() brukes til å konsumere dataene. Litt som når du awaiter et promise:

const activities = await fetchActivities();

Forskjellen er at use-funksjonen vil suspende promiset frem til det er ferdig. Det betyr at fallback- verdien i Suspense vil vises:

// ActivitiesPage

<Suspense fallback={<ActivityListFallback />}>
  <ListActivities
    activitiesPromise={activitiesPromise}
    onToggle={onToggle}
  />
</Suspense>

Det kan se slik ut:

Suspense viser fallbackverdi før promise-et er resolved

Når promiset til slutt resolves (altså har suksess), vil barna i Suspense bli vist, og dataene fra promiset kan leses av. Om promiset skulle bli rejecta (altså ved feil), blir nærmeste ErrorBoundary aktivert.

At promise-et blir kasta av use-funksjonen gjør at disse laste-tilstandene og feil-tilstandene koordineres automatisk. Så må du selv legge opp til Suspense- og Error- grenser der det passer seg.

Mutering og use()

Da jeg selv lekte meg frem med use(), var det deilig å se hvor mye kode som kunne droppes ved henting av data. Men da jeg ville endre dataene, her huke av en aktivitet, var det verre.

I Herringtons video, var ny datahenting automatisk med parameteren som endret seg, trigget en re-render nå med ny paramater, så skjedde en ny datahenting i initiell verdi med useState-en. Han endret ikke dataene direkte med setState. Mens jeg ville mutere en aktivitet, så hente dataene på nytt. Jeg leita gjennom mange bloggposter, mange videoer og måtte til slutt ty til Chattern. Den ga meg mange løsninger, men ingen av dem hadde jeg helt troa på:

Chattern gir ikke alltid de enkleste løsningene

Til slutt prøvde jeg min naive tilnærming ved å kjøre setState igjen med ny fetchActivities:

// ActivitiesPage

async function onToggle(id: string) {
  // 👇 API-kall for å endre complete-status på aktivitet
  await toggleActivity(id);
  // 👇 Hente ny aktiviteter og sette promise-et med setState
  setActivitiesPromise(fetchActivities());
}

Det fungerte! Men du ser kanskje noen problemer?

Toggling av én aktivitet bør ikke føre til lasting av hele lista

Selv om jeg bare endrer én ting, vises hele fallback-lista for aktivitetene.

Det kan være rett i noen tilfeller, men jeg ville at dataene lastes inn i bakgrunnen ved toggling. Det ble løst med useDeferredValue:

// ActivitiesPage

const deferredPromise = useDeferredValue(activitiesPromise);

useDeferredValue sier at oppdateringen av verdien er i lavprioritet, så det kan skje ved neste render. Det leder til at gammel data vises mens promise-et for ny data kan laste, uten å trigge ny fallbackverdi.

Og da fikk jeg kun fallback-verdi ved første innlastning:

Toggling av én aktivitet laster ikke lenger hele lista

Dette var fremgang. Men brukeren får lite respons når hen trykker på en toggle. Det kan vi løse med transitions.

Ved å bruke useTransition kan vi starte en handling, og mens den handlingen skjer, har vi en laste-status:

// ActivityListItem

export function ActivityListItem({
  activity,
  onToggle,
}: Props) {
  /*
    👇 isPending er lastestatus mens transition pågår,
    som vi så kan bruke for å vise laste-tilstand i UI-et
  */
  const [isPending, startTransition] = useTransition();

  async function handleToggle() {
    // 👇 startTransition gjør om en handling til en transition
    startTransition(async () => {
      await onToggle(activity.id);
    });
  }

Det ser slik ut:

Toggling av én aktivitet gir nå respons på at noe foregår

Brukeren får litt feedback, med laste-indikator (lavere opacity), men det føles ikke veldig responsivt når check-merket kommer par sekunder senere. Det kan vi løse med optimistisk oppdatering:

// ActivityListItem

export function ActivityListItem({
  activity,
  onToggle,
}: Props) {
  /*
    👇 useOptimistic lar deg enklere lage optimistiske oppdateringer,
    hvor du antar verdien før API-kallet responderer.
    
    Verdien rulles tilbake til start-verdi (her activity.completed) 
    etter transition er ferdig.
  */
  const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(
    activity.completed
  );
  const [isPending, startTransition] = useTransition();

  async function handleToggle() {
    startTransition(async () => {
      //👇 Ved toggle setter vi ny verdi med en gang.
      setOptimisticCompleted(!optimisticCompleted);
      await onToggle(activity.id);
    });
  }
  
  return (
  ...
	  <input
	  type="checkbox"
	  checked={optimisticCompleted}
	  onChange={handleToggle}
/>

Nå har vi umiddelbar respons!

Toggling av aktivitet oppdateres automatisk - men med litt flikring

Men her ser vi flikring. Vi får umiddelbar respons, så flikrer det mellom check-status, så blir det rett igjen.

Årsaken er:

  • først setter vi optimistisk verdi (checked)
  • Så rulles verdien tilbake idet transition i item er ferdig til startverdi (un-checked)
  • Så blir ny datahenting ferdig og settes til rett verdi (checked)

Det vi ønsker er at optimistisk verdi bevares frem til ny datahenting er ferdig. Det kan vi koordinere ved å bruke transition:

// ActivitiesPage
  
  // 👇 Vi sender ned funksjon til child
  async function onToggle(id: string) {
    // 👇 Muterer activity
    await toggleActivity(id);
    
    // 👇 Vi venter til ny datahenting er ferdig
    const nextData = await fetchActivities();

    /*
      👇 Vi wrapper setState i transition,
      så tilstands-setting ikke skjer med engang.
      Uten transition her, får vi flikring.
    */
    startTransition(() => {
      setActivitiesPromise(Promise.resolve(nextData));
    });
  }
  
  // ActivityListItem
  
  async function handleToggle() {
    startTransition(async () => {
      // 👇 Optimistisk verdi bevares frem til transitions er ferdig
      setOptimisticCompleted(!optimisticCompleted);
      await onToggle(activity.id);
    });
  }

Og resultatet blir:

Flikring er fikset - vi er nesten i mål

Men én siste ting. La oss få inn animasjoner. For med den nye ViewTransition- komponenten kan vi wrappe elementer som er i transition, og få en jevn overgang fra det ene snapshotet til det andre:

/*
  👇 ViewTransition animerer fra snapshotet før en transition 
  til etter.
*/
<ViewTransition>
  <Suspense fallback={<ActivityListFallback />}>
    <ListActivities
      activitiesPromise={deferredPromise}
      onToggle={onToggle}
    />
  </Suspense>
</ViewTransition>

Og resultatet blir slik:

Ved toggle ser du en svak fade-animasjon

Fjooo! Ingen flikring, umiddelbar respons oooog animasjoner!

Fremtiden til React er async

Det kan hende du nå stiller meg samme spørsmål som jeg stilte Chattern:

Transitions er komplisert - men vi trenger ikke gjøre alt selv

Tja.

I Hanlons foredrag sier han at vi i fremtiden ikke kommer til å skrive all transition-funksjonalitet selv. I stedet kan vi forvente at data- og routing bibliotek håndterer det for oss.

Hanlon sier vi kan få til Async React ved 3 områder: design, router og data.

En del av dette er støttet allerede. For eksempel kan vi bytte ut use() med useSuspenseQuery fra TanStack Query. Og Next.js har transition-støttet routing.

Fra designbibliotek, som shadcn, kan vi etter hvert forvente at transitions bare funker. Da vil vi som konsumere bare sende en handling ned i en prop som benytter seg av action-mønsteret — som vi gjorde med handleToggle — og forvente at propen blir wrappa i en transition.

React-teamet, med Hanlon, har startet en arbeidsgruppe for å gjøre transitions enklere — og å lære det bort. Allerede har de drodlet på noen ideer, som å bytte ut datahentings-eksempelet i React docs fra å bruke useEffect til å bruke use().

For det ser ut som at transitions er fremtiden til React. Det er bygd for å koordinere arbeid i React — både for enklere kode for deg, og for en bedre brukeropplevelse for alle.

👋 Det var alt for denne gang. Ha en riktig fin uke!

Skrevet av