Hopp til hovedinnhold

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

Publisert:8. juli
Skrevet av:Marcus Haaland

Avanserte React Query-mønstre og livstegn fra Radix — i denne ukas ForrigeUke.

Dette var uka for 5-minutts-jobber og helt nye produkter — og 1907 ting som skjedde i frontendverdenen!

«<ForrigeUke /> er en artikkelserie som oppsummerer hva som skjedde i frontend-verden i uka som var.»
palme og strandstol på øy
Ukas utgave handler om et verktøy med en veldig sommerlig logo. (https://tanstack.com/)

Avanserte React Query-mønstre

Youssef Benlemlih har lagd en video hvor han deler 15 (én av dem forsvant i redigeringen 🥲) mønstre i React Query. Selv om jeg har brukt React Query en del fra før, var det flere mønstre jeg ikke visste om. Også er det jo deilig å få mata noen eksempler, istedenfor å grave i docs. Her er noen av dem:

select for transformering

Den mest naturlige måten å transformere dataene, er å først hente data, så endre dem:

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});

const fullNames = data?.map(user => `${user.firstName} ${user.lastName}`);

Men visste du at du kan transformere dataene i React Query før du tar dem i bruk? select lar deg gjøre det:

const { data: fullNames } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (data) => data.map(user => `${user.firstName} ${user.lastName}`),
});

Dette har også en optimaliseringsgevinst, for med select vil re-render bare skje om resultatet av select endrer seg – ikke bare om det underliggende dataobjektet er nytt.

queryOptions for gjenbruk

Funksjoner jeg trenger flere steder pleier jeg å legge ett sted og eksportere for gjenbruk:

export function useUsersQuery(isLoggedIn: boolean) {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    enabled: isLoggedIn,
  });
}

Dette har problemet at om du skal sende inn flere tilvalg til query-en, får funksjonen stadig flere parametere, og som du i noen tilfeller ikke engang tar i bruk.

En måte å komme rundt dette på, er å ta i bruk queryOptions. Istedenfor å gjenbruke hele query-en, kan du heller gjenbruke det som alltid er likt:

// queries/users.ts
export const usersQueryOptions = queryOptions({
  queryKey: ['users'],
  queryFn: fetchUsers,
});

// someComp.tsx
export function UserList({ isLoggedIn }: { isLoggedIn: boolean }) {
  const { data } = useQuery({
    ...usersQueryOptions,
    enabled: isLoggedIn,
    staleTime: 60000,
  });
  
  ...
}

Mens tilvalg som om funksjonen er aktiv eller unntak på staleTime, kan du definere idet du bruker useQuery-en. Dette gir en utrolig fleksibel måte å bruke query-ene.

meta for tilleggsfunksjoner

Helt grunnleggende i React Query er dette mønsteret: 1) du henter data, og 2) når du oppdaterer den, invaliderer du dataene:

export function useUserQuery(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
}

export function useUpdateUserMutation() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateUser,
    onSuccess: (_, updatedUser) => {
      queryClient.invalidateQueries({
        queryKey: ['user', updatedUser.id],
      });
    },
  });
}

For hver mutering definerer vi useQueryClient, som vi bare bruker til å invalidere dataene i en onSuccess. Det kan bli en del duplikat. Hva om vi slapp å gjenta oss?

Med meta-nøkkelen i queryClient, kan vi definere ekstra felter på query-ene og muteringene våre. Ved å legge til et felt, for eksempel “invalidatesQuery”, kan vi med færre linjer kode få gjort dette vanlige mønsteret.

Da kan vi først sette opp funksjonen i queryClienten:

// setupQueryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  mutationCache: {
    onSuccess: (data, _variables, _context, mutation) => {
	    /* 👇 Ved suksess, hvis det fins en funksjon invalidatesQuery
              på muteringen, invaliderer vi den gitte nøkkelen
        */
      const invalidates = mutation.meta?.invalidatesQuery;
      if (invalidates) {
        queryClient.invalidateQueries({ queryKey: invalidates(data) });
      }
    },
  },
});

Så når vi bruker useMutation, kan vi kalle på vår nye invalidatesQuery-funksjon:

export function useUpdateUserMutation() {
  return useMutation({
    mutationFn: updateUser,
    meta: {
      // 👇 Ved mutering oppgir vi nøkkelen via meta.invalidatesQuery-feltet
      invalidatesQuery: (updatedUser: User) => ['user', updatedUser.id],
    },
  });
}

Er dette en forbedring?

For meg blir det litt for magisk, så jeg foretrekker den verbose varianten, så jeg vet hva som foregår. Men likevel artig å se bruk av queryClient-en for å sette opp gjenbrukbare funksjoner. Kanskje dette meta-feltet også er nyttig for annen funksjonalitet?

Paginering og prefetching

Benlemlih viser også hvordan du kan lage paginerings-funksjonalitet med useQuery. Det gjør du slik:

export function UsersTable() {
  // 👇 Holde rede på nåværende side
  const [page, setPage] = useState(1);

  const { data } = useQuery(getUsersQueryOptions(page, 20));

  function onNextPageClick() {
    setPage(page + 1);
  }

  return (
    <>
      <UsersList users={data} />
      <button onClick={onNextPageClick}>Neste side</button>
    </>
  );
}

Vi har en tilstand for å vite nåværende side, og bruker den verdien for å hente de relevante dataene.

En liten hack er å ikke bare hente for nåværende side, men også neste side. Da vil brukeren få se den neste hentingen umiddelbart:

export function UsersTable() {
  const [page, setPage] = useState(1);
  const queryClient = useQueryClient();

  const { data } = useQuery(getUsersQueryOptions(page, 20));

  // 👇 Hent data også for neste side
  useEffect(() => {
    queryClient.prefetchQuery(getUsersQueryOptions(page + 1, 20));
  }, [page, queryClient]);

  function onNextPageClick() {
    setPage((prev) => prev + 1);
  }

  return (
    <>
      <UsersList users={data} />
      <button onClick={onNextPageClick}>Neste side</button>
    </>
  );
}

Dette er ekstra nyttig hvis du vet at brukerne dine ofte klikker seg videre med én gang — da sparer du dem for ekstra spinnere.

Unngå spinner-helvete med suspense

Apropos spinnere, ser applikasjonen din slik ut?

https://x.com/acdlite/status/991503599246098432

Koden bak kan være en rekke datahentinger per komponent:

export function UsersTable() {
  const {
    data: users,
    isLoading: isLoadingUsers,
  } = useQuery(getUsersQueryOptions());

  const {
    data: roles,
    isLoading: isLoadingRoles,
  } = useQuery(getRolesQueryOptions());

  const {
    data: permissions,
    isLoading: isLoadingPermissions,
  } = useQuery(getPermissionsQueryOptions());

  return (
    <>
      {isLoadingUsers && <p>Laster brukere...</p>}
      {isLoadingRoles && <p>Laster roller...</p>}
      {isLoadingPermissions && <p>Laster rettigheter...</p>}

      {!isLoadingUsers && users && (
        <UsersList users={users} roles={roles} permissions={permissions} />
      )}
    </>
  );
}

Datahenting fra mange kilder kan føre til spinner-helvete og hoppende grensesnitt, såkalt “cumulative layout shift”. En rask løsning på det er å samle opp loading-tilstander:

export function UsersTable() {
  ...
  
  const isLoading = isLoadingUsers || isLoadingRoles || isLoadingPermissions;

  return (
    <>
      {isLoading && <p>Laster ...</p>}

      {!isLoading && users && (
        <UsersList users={users} roles={roles} permissions={permissions} />
      )}
    </>
  );
}

Å samle laste-tilstander forutsetter at vi har all datahentingen på samme nivå, som kan føre til at vi mister kapsuleringen for hver komponent.

Et alternativ er å heller benytte suspense. Istedenfor å definere hvordan grensesnittet skal se ut når det laster inni komponenten, kan du heller hvile på nærmeste suspense-grense utenfor komponenten.

For å bruke suspense med React Query, bytter du ut useQuery med useSuspenseQuery, og så kan du fjerne fjerne loading-tilstandene:

export function UsersTable() {
  const [page, setPage] = useState(1);

  const { data: users } = useSuspenseQuery(getUsersQueryOptions(page, 20));
  const { data: roles } = useSuspenseQuery(getRolesQueryOptions());
  const { data: permissions } = useSuspenseQuery(getPermissionsQueryOptions());

  return (
    <>
      <UsersList users={users} roles={roles} permissions={permissions} />
      <button onClick={() => setPage((p) => p + 1)}>Neste side</button>
    </>
  );
}

Så sørge for at du har en suspense-grense utenfor:

<Suspense fallback={<Spinner />}>
  <UsersTable />
</Suspense>

Suspense skjønner at data lastes, siden promise-et ikke er løst enda. Nå vises fallback-spinneren frem til dataene inni UsersTable er ferdig med å laste.

Raskere løsninger med optimistiske oppdatering

Benlemlih viser også to måter å gjøre et av de kuleste mønstrene jeg vet om: optimistisk oppdatering. Altså det å oppdatere grensesnittet før du vet at dataene er tilstede, som gir en mer responsiv brukeropplevelse.

Om videoen ikke gir deg nok forståelse for dette mønsteret, er det bare å ta en titt på en guide jeg har skrevet før, som også viser optimistisk oppdatering i React uten å bruke et bibliotek.

Enten du er ny eller gammel traver når det kommer til React Query, er Benlemlihs video absolutt verdt å ta en titt på:

Radix svarer på dramaet

Et par uker tilbake var Radix døende. Det virket hvert fall slik, basert på fuzzen rundt det. Jeg begynte å revurdere tidligere komponentbibliotek-valg, og for mitt kommende prosjekt, måtte jeg plutselig se etter alternativer.

Det var derfor gledelig å se at Radix forrige uke svarte på dramaet, og ønsker nå å streame ukentlig for å vise at de lever. Det ga nok flere designsystem-utviklere litt mer hvilepuls.

Likevel tror jeg konkurrent BaseUI fikk noen ekstra øyne på seg under dramaet, så jeg kommer til å følge med på om dette er et mer lovende alternativ. Uansett fint å se at et stødig komponentbibliotek som Radix fortsetter utviklingen. Første stream er allerede 10. juli.

👋 Det var alt for denne gang. Håper du har en fortsatt fin sommer, enten det er på kontoret eller en strand!

Skrevet av