Hopp til hovedinnhold

Når vi bruker apper i dag er ikke flaskehalsen lite datakraft. Det er heller variasjon i nettverk, enten vi må vente på systemer som snakker sammen eller vi tilfeldigvis befinner oss i en tunnel.

Derfor beveger React seg mot async som standard: vi bygger grensesnitt som antar at operasjoner kan være trege. Når noe går sakte, får brukeren feedback fra første tastetrykk til data lastes inn. Når nettverket er raskt vil appen føles umiddelbar.

Dette høres jo kjempebra ut. Så jeg testet det ut. Med nye React-funksjoner som use, useTransition, suspense og ViewTransition. Resultatet for brukeren er mer responsive grensesnitt, men som utvikler er det mange snublefeller på veien dit.

I denne posten får du bli med på frustrasjonene fra da jeg testet ut async React. Jeg viser hva fellene sier om hvordan React egentlig fungerer, og hva de avslører om hvordan React-teamet ser for seg at vi bygger apper fremover.

Tilbakemelding mens vi venter på data

Suspense er en av måtene vi gir brukeren tilbakemelding mens vi venter på at noe skjer. For å aktivere suspense, trenger vi å hente data med et verktøy som aktivererer suspense. For det kan vi bruke den nye use-funksjonen.

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. use brukes til å konsumere dataene.

Litt som når du awaiter et promise:

// const activities = await fetchActivities();
const activities = use(activitiesPromise);

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:

todoapp med skjelettvisning ved refresh
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.

Hente nye data etter endring

For å mutere data, kan vi sette tilstand med ny fetch:

// 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?

Alternative text: todoapp hvor toggling laster inn alt på nytt
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 løste jeg 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:

toggle på en todoitem tar lang tid før noe responderer
Toggling av én aktivitet laster ikke lenger hele lista

Respons mens vi endrer

Vi klarte å endre data etter brukeren sjekket av en handling, men det tar lang tid før brukeren forstår at noe skjer. 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:

Alternative text: nå gir todoitem svakere gråfarge ved toggle
Toggling av én aktivitet gir nå respons på at noe foregår

Optimistisk visning

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, via useOptimistic-hooken:

// 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!

Alternative text: toggle endrer umiddelbart, men flikrer
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)

Grunnen til at ny datahenting ikke inkluderes i transition, er at enn så lenge vil tilstands-oppdateringer etter await i en transition ikke markeres som transition. Derfor må vi legge til en transition på setActivitiesPromise:

// ActivitiesPage
  
  // 👇 Vi sender ned funksjon til child
  async function onToggle(id: string) {
    // 👇 Muterer activity
    await toggleActivity(id);

    /*
      👇 må starte ny transition her, 
      siden tilstands-oppdatering etter await 
      ikke blir merket som transitions.
      
      Uten transition her, får vi flikring.
    */
    startTransition(() => {
      setActivitiesPromise(fetchActivities());
    });
  }
  
  // ActivityListItem
  
  async function handleToggle() {
    startTransition(async () => {
      // 👇 Optimistisk verdi bevares frem til transitions er ferdig
      setOptimisticCompleted(!optimisticCompleted);
      await onToggle(activity.id);
    });
  }

Og resultatet blir:

Alternative text: toggle endrer umiddelbart, uten flikker
Flikring er fikset - vi er nesten i mål

Veiled brukeren med animasjoner

Men én siste ting. La oss få inn animasjoner. For med den nye ViewTransition- komponenten kan vi pakke inn 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:

toggle fører til svak fade-animasjon fra checked til unchecked
Ved toggle ser du en svak fade-animasjon

Fjooo! Ingen flikring, umiddelbar respons oooog animasjoner!

Async React i tre lag

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

chatteboble med “dette så altfor komplisert ut. fins det enklere løsninger?”
Transitions er komplisert - men vi trenger ikke gjøre alt selv

Tja.

Ricky Hanlon sier 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.

Det var Ricky Hanlons foredrag som pirret nysgjerrigheten min på async React. Han forklarer hvordan de har jobbet med dette lenge, og hvordan de ser for seg fretiden blir.

Foredraget er oppstykket, på grunn av noen utfordringer i demoen (async React er vanskelig!).

Se Ricky Hanlons foredrag fra React Conf her:

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

Mye av async React er støttet allerede. For datahenting kan vi bytte ut use med useSuspenseQuery fra TanStack Query, som Petter viste i en tidligere kalenderluke. For routing har flere router-bibliotek transition-støttet routing, som routingen i Next.js.

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.

Ettersom vi utviklere sjelden implementerer egne router- eller dataløsninger, tror jeg det er på designlaget vi kommer til å forholde oss til async React en god stund fremover.

React-teamet 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.

Fremtiden til React er async

Async React er komplekst, men i fremtiden vil vi få mye gratis. Der raske apper føles umiddelbare og trege apper føles forståelige. Inntil da er det verdt å kjenne til hvordan de underliggende funksjonene fungerer — og de tilhørende snublefellene.

Liker du innlegget?

Del gjerne med kollegaer og venner