Hopp til hovedinnhold
Fag i Bekk/<ForrigeUke uke="36" ...<ForrigeUke uke="36" år=2025" />

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

Publisert:9. september
Skrevet av:Marcus Haaland

Færre effekter, færre bugs: når du bør utlede verdier i stedet for å synkronisere dem — i denne ukas ForrigeUke.

Dette var uka for vanskelig navngivning og rike i-er — og 594 ting som skjedde i frontendverdenen!

«<ForrigeUke /> er en artikkelserie som oppsummerer hva som skjedde i frontend-verden i uka som var.»
Gutt sykler. Setter pinne i hjul med tekst "legger til setState i useEffect uten guard". Ramler med tekst "too many re-renders".
Du skal unngå useEffect når du kan, men det er ikke lett å vite når useEffect er relevant. I denne utgaven får du tips for begge deler.

Ikke synkroniser klienttilstand fra servertilstand. Utled verdien

Sjekk ut dette skjemaet for valg av ansvarlig person for en oppgave:

Skjema med Select med valg av personer
Illustrasjon av problemet: hva skjer om du har et aktivt valg av en person i Select-en, men personen fjernes fra serveren?

Du har en tilstand for alle hentede brukere i Select-en. Også har du en tilstand for det nåværende valget i Select-en. Hvordan ville du ha håndtert tilstanden for det nåværende valget om du valgte en bruker, men den brukeren ble så slettet?

For å si det mer generelt: Hvordan håndtere at klienttilstand er avhengig av servertilstanden?

Forrige uke var det dette problemet som fyrte i gang en kaskade av diskusjoner på Reddit rundt hvordan du holder orden på klient- og servertilstand, og hvilket verktøy som er rett. Forfatter av tråden sin løsning var å sjekke om den hentede dataen hadde blitt endret, og om brukeren fortsatt var der. Om ikke lenger der, sett nåværende tilstand til null:

const useSelectedUser = () => {
  // 👇 Hente brukere
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  
  // 👇 Tilstand for nåværende verdi i Select
  const { selectedUserId, setSelectedUserId } = useUserStore()
  
  // 👇 Hvis data er oppdatert og ikke lenger har valgt brukerId,
  // sett nåværende id til null
  useEffect(() => {
    if (!users?.some((u) => u.id === selectedUserId)) {
      setSelectedUserId(null)
    }
  }, [users, selectedUserId])

  return [selectedUserId, setSelectedUserId]
}

Men da TanStack Query-vedlikeholderen TkDodo kom hjem fra ferie og så posten, gikk varsellampene på for ham. Å sette tilstand i en useEffect er en “code smell” 👃. Det fins bedre måter.

Utlede tilstand

Eksempelet over innebar tilstand via Zustand, et verktøy for global klienttilstand. Men tilstanden kunne like gjerne vært via en useState.

Å synkronisere tilstand basert på en annen tilstand ligner en annen nybegynnerfeil: å legge til flere tilstander, når du bare kan utlede verdiene.

Et typisk eksempel er om du ønsker å vise fornavn og etternavn sammen: Hvor mange useStates trenger du?

En mulig løsning er å ha en tilstand fornavn, etternavn og fullt navn. Så syncer du fullt navn med en useEffect:

export function NameForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(firstName + " " + lastName);
  }, [firstName, lastName]);
  
  ...

Syns du det ble mange useStates?

Enig. Du legger også til kompleksitet med en useEffect.

Du kan heller utlede det fulle navnet navn fra de andre tilstandene. Og da slipper du også en useEffect:

const fullName = firstName + " " + lastName

TkDodo peker på at du kan bruke tilsvarende tankegang ved sync av klienttilstand som er avhengig av servertilstand:

const useSelectedUser = () => {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  const { selectedUserId, setSelectedUserId } = useUserStore()

  // 👇 Vi gir tilbake en foreløpig verdi 
  // - altså ikke tilstandsverdien direkte
  const selectedId = users?.some((u) => u.id === selectedUserId)
    ? selectedUserId
    : null

  return [selectedId, setSelectedUserId]
}

Vi har logikken pakka inn i en hook og gir tilbake vår utledede verdi, istedenfor å oppdatere tilstanden. Ved ingen bruker, leverer vi tilbake null. Med denne løsningen slipper vi altså å sette verdien igjen! En bonus-effekt er at når brukeren blir lagt til i servertilstanden igjen, blir Select-en oppdatert igjen.

En ulempe med denne løsningen er at vår globale tilstand ikke er sannheten lenger. Så du må alltid lese verdien fra din custom hook. Det er ikke nødvendigvis et problem, mener TkDodo, om du tenker at tilstanden er et valg som ble gjort i grensesnittet — og ikke siste validerte verdi og sannhet.

Initiell data i skjema

Å utlede klienttilstand fra servertilstand er også vanlig i skjemaer. Om du ønsker å sette initiell data i et skjema, som når du er i redigeringsmodus, er det kanskje intuitivt å bruke en useEffect for å sette verdiene i skjemaet, basert på dataene du har hentet:

function UserSelection() {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  
  const [selection, setSelection] = useState()

  // 👇 Bruk første verdi som standard-verdi
  useEffect(() => {
    if (data?.[0]) {
      setSelection(data[0])
    }
  }, [data])

  // ...
}

Du ser igjen en setState inni en useEffect, så jeg håper du aner uro? 👃

Denne koden er både unødvendig komplisert og introduserer en bug. Bugen kommer av at useQuery vil hente data flere ganger for å holde data oppdatert. Så om dataene endrer seg, og brukeren beveger seg til en annen fane og tilbake, kan skjemafelt-verdien endre seg, og brukeren mister det hen har skrevet inn i feltet.

Det går an å legge til en sjekk a la “hasSetInitialData”, men så fins det enklere måter.

En måte er å igjen lage en foreløpig verdi, istedenfor å sette verdien direkte:

function UserSelection() {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })
  
  const [selection, setSelection] = useState()

  // 👇 Hvis ikke valgt i skjemaet, bruk initiell data
  const derivedSelection = selection ?? data?.[0]

  // ...
}

Kunne useQuery sin select ha blitt brukt?

Den oppmerksomme (eller som kanskje har lest tidligere utgave av ForrigeUke? 😎) kan se at en løsning kunne vært å bruke useQuery- funksjonen select. I kommentarfeltet til bloggposten er det også en bruker som undrer seg om nettopp det er en gyldig løsning.

Som TkDodo skriver, kunne det sett ut slik:

function UserSelection() {
  const [selection, setSelection] = useState()

  const { data: derivedSelection } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    // 👇 Transformerer verdiene før det er tilgjengelig i data-variabelen
    select: (users) => selection ?? users[0]
  })

  // ...
}

Men dette går imot det vi egentlig ønsker å oppnå. Vi ønsker ikke å endre servertilstanden direkte. Vi ønsker å ha en verdi som er fra klienttilstanden basert på servertilstanden.

En annen grunn er også at vi kan ønske å benytte dataene om brukere andre steder, uten at vi bare bryr oss om den første brukeren. Så da gir det mening å separere ut den ene hooken som håndterer initielle data eller valg og den andre hooken som henter alle brukere.

Når er useEffect rett å bruke?

Siden vi har sett på at du bør unngå useEffect, når er det egentlig rett?

React- dokumentasjonen sier at du bruker useEffect til å synkronisere en komponent med et eksternt system. Som vi har sett, skal vi ikke bruke useEffect til å holde React-tilstand i sync. Et eksternt system kan for eksempel være en server, nettleser-API eller et tredjepartsbibliotek.

Her har vi et kart, så vi kan bruke useEffect til å opprette kartet, reagere på tilstandsendringer, her zooming, og til opprydding:

export default function Map({ zoomLevel }: { zoomLevel: number }) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const mapRef = useRef<MapWidget | null>(null);

  useEffect(() => {
	// 👇 Oppretting
    mapRef.current = new MapWidget(containerRef.current!);
    
    // 👇 ... og opprydding
    return () => {
      mapRef.current?.destroy();
      mapRef.current = null;
    };
  }, []);

  // 👇 Reager på tilstand
  useEffect(() => {
    mapRef.current?.setZoom(zoomLevel);
  }, [zoomLevel]);

  return <div ref={containerRef} />;
}

Når du begynner å lære React er det typisk fetching du bruker useEffect til. Men datahenting er komplisert (hei caching, cache invalidering, race conditions 👋), så derfor bør du du heller bruke egne verktøy som tar seg av dette, som TanStack Query.

Jeg forlater deg med en situasjon vi nå kanskje kan unngå. Så snakkes vi igjen neste uke 👋

Person1 og person2 diskuterer om useEffect er greit så lenge funksjonaliteten fungerer

Del kunnskapen

Har du en kollega som også hadde dratt nytte av denne artikkelen?

Skrevet av

Mer fra Fag i Bekk

Nå er du ved veis ende. Gå til forsiden hvis du vil ha mer faglig påfyll.

Til forsiden