Få sideeffektene dine til å bare kjøre én gang med useEffectOnce

Tarjei Skjærset i Bekk gir et knippe gode tips om hvordan du takler useEffect i React, og hvordan du bygger et hook som hjelper deg å kjøre engangs-sideeffekter.

📸: Nick Fewings / Unsplash.
📸: Nick Fewings / Unsplash. Vis mer

useEffect er hooken man bruker i tilfeller hvor man vil utføre en sideeffekt når en komponent rendrer. Men, hva gjør man dersom man ønsker at sideeffekten skal utføres bare en gang? Tenk deg at du har den følgende koden.


useEffect(() => {
  alert('Velkommen til min blogg!');
}, []);

Dette er vel og bra, men man vil jo ikke ønske brukerene sine velkommen for hver render — helst bare en gang! Uheldigvis for oss er denne koden nesten riktig, og oppfører seg tilsynelatende riktig. Men dette er egentlig bare en implementasjonsdetalj. Grunnen til at sideeffekten bare utføres en gang i dette tilfellet er at den ikke har noen avhengigheter, men dette kan fort endre seg.

Som regel er jo ikke koden vår så kort og da har man ofte avhengigheter man ikke helt vet når endrer seg. Den enkleste måten å illustrere dette på er i det tilfellet hvor vi får sendt inn ønske-velkommen-funksjonen, eksempelvis kalt `onWelcome`. Vi refaktorerer og får den følgende koden:

useEffect(() => {
  onWelcome();
}, [onWelcome]);

Uendelig løkke

Og vipps så ønsker vi brukeren vår velkommen i en uendelig løkke. Her kan det for noen være utrolig fristende å løse problemet med å fjerne `onWelcome` fra dependency-lista, men ikke gjør det! Det vil tilsynelatende virke i dette tilfellet, men det er aldri riktig, og leder i følge Dan Abramov alltid til bugs.

Dependency-lista brukes av React som en ytelsesoptimalisering, og kan under ingen omstendigheter brukes av utvikleren til å bestemme eller kontrollere når effekten utføres. Konseptuelt sett skal effekten kunne kjøre på hver eneste render, dependency-lista gjør det mulig for React å optimalisere bort noen, men ikke alle, av utførelsene. Å jukse med dependency-lista er bare å slåss mot React, og det er en tapende kamp!

If som passer på

Men dette høres motsigende ut, hvordan skal effekten kunne kjøre på hver render, i alle fall i teorien, når vi bare vil at sideeffekten skal utføres en gang? Vel, trikset er at effekten må alltid kjøre, ja, men vi trenger ikke alltid å gjøre noe. La meg vise deg med kode:

const [hasRun, setHasRun] = useState(false);

useEffect(() => {
  if (!hasRun) {
    onWelcome();
    setHasRun(true);
  }
}, [hasRun, onWelcome]);

Vi introduserer en tilstandsvariabel med useState , som husker om vi har utført handlingen vi ønsker, og passer på å kalle callbacken onWelcome bare om den ikke har kjørt før. Så denne effekten kjører altså potensielt på hver eneste render, men if-testen inni passer på at den faktiske sideeffekten vi er interessert i, ikke alltid blir utført. Lærdommen her er at man skal bruke kode man selv skriver til å kontrollere når og hva som skjer, ikke dependency-lista.

React-teamet jobber hardt med å automatisere generering av dependency-lista, så håpet er at i framtida vil vi slippe å skrive og å vedlikeholde denne, og at React selv klarer å utlede avhengighetene effekten har.

Lag en egen hook

Så la oss trekke ut dette i en custom hook! Det er egentlig ganske rett fram herifra, det eneste vi mangler er å ha det i en gjenbrukbar funksjon. Vi renamer også onWelcome til callback for å generalisere den ytterligere. Noe å tenke på her er at koden blir i en viss forstand enklere av å være mer generell, fordi den vet mindre om hva callbacken gjør.

At det er en velkomsthilsen har strengt tatt ikke hooken vår noe med å gjøre, og skaper en unødig tett kobling mellom hooken og forretningslogikken.

const useEffectOnce = (callback) => {
  const [hasRun, setHasRun] = useState(false);

  useEffect(() => {
    if (!hasRun) {
      callback();
      setHasRun(true);
    }
  }, [hasRun, callback]);
};

På denne måten kan vi bruke useEffectOnce mer eller mindre på samme måte som vi ville brukt useEffect tidligere.

useEffectOnce(() => {
  alert('Velkommen til min blogg!');
});

Legg merke til at vi her ikke har noe behov for noen dependency-liste eller `useCallback`. Dette er fordi effekten uansett bare skal utføres en gang i alle tilfeller, og da trengs ingen ytelsesoptimalisering.

Husk at manglende avhengigheter alltid er en bug! Å bruke dependency-lista for å kontrollere sideeffekter er som å drikke to liter vann før du legger deg kvelden før eksamen. Det er enklere og mindre risikabelt å bare stille vekkerklokka.

Bonusmateriale

Hva om effekten vår er avhengig av asynkron data? Hva om vi vil si velkommen til brukeren vår basert på et brukernavn, som vi må gjøre et nettverkskall for å hente? Dersom vi har den følgende koden:

useEffectOnce(() => {
  alert(`Velkommen til min blogg, ${username}!`);
});

er det lite vits i at effekten utføres før `username` har lasta. Den vil heller aldri utføres på nytt når brukernavnet er ferdig lasta. Så la oss utvide hooken vår til å støtte denne usecasen! Men først la oss endre måten vi bruker den på.

En måte vi kan gardere oss på er å la være å konstruere callbacken dersom vi ikke har dataen den trenger. For å være helt eksplisitte, kan vi i første omgang trekke ut callbacken i en navngitt funksjon.

Her bruker vi en ternary for å være helt eksplisitte. Dersom brukernavnet finnes, konstruerer vi callbacken, og dersom det ikke finnes ennå, setter vi velkommenAlert til undefined.

Dersom vi vil forenkle litt, kan vi bruke et vanlig JavaScript-pattern for å definere potensielt ikke-eksisterende data, nemlig &&-operatoren.

const velkommenAlert =
  username
    && () => {
        alert(`Velkommen til min blogg, ${username}!`);
    };

useEffectOnce(velkommenAlert);

Og helt til slutt kan vi flytte funksjonen tilbake til å være inline, heller enn å mellomlagre den i en navngitt konstant.

useEffectOnce(username && () => {
   alert(`Velkommen til min blogg, ${username}!`);
});

Og så er vi nesten tilbake til start, men med en liten endring som gjør callbacken optional! Enten har vi en funksjon eller så har vi undefined. Forhåpentligvis gjorde denne stegvise transformasjonen det enklere å se hva det er vi har gjort. Hva som er mest leselig eller best kode, får hver enkelt bestemme.

Men vi er ikke ferdig! For dette løser ikke i seg selv problemet vårt. Vi må fortsatt endre selve hooken for å støtte dette nye APIet vi vil bruke. Heldigvis er endringen ganske enkel.

const useEffectOnce = (callback) => {
  const [hasRun, setHasRun] = useState(false);

  useEffect(() => {
    if (callback) {
      if (!hasRun) {
        callback();
        setHasRun(true);
      }
    }
  }, [hasRun, callback]);
};

Vi legger simpelthen til en ekstra if-test, som sjekker at callbacken eksisterer, før vi i det hele tatt vurderer å kjøre den. Her kan det debatteres om det ikke er mer fornuftig å samle begge if-testene med en “and”, slik at det blir en linje: if (callback && !hasRun). Dette er selvfølgelig alltid en gyldig transformasjon av nøsta if-blokker, men jeg tror jeg foretrekker den første måten fordi det gjør intensjonen vår tydeligere. Nemlig at vi først sjekker om callbacken er tilgjengelig, og bare etter den blir det, blir den vurdert utført. Etter at den første gang blir tilgjengelig, blir den utført, ettersom hasRun er initielt false, og etter det vil den aldri bli kjørt igjen fordi hasRun aldri vil bli false på noen måte.

Og slik har vi en slags hook som utfører en sideeffekt så snart den kan, men bare en gang.

Har du en krok-idé?

Vi har en rekke enkle hooks som er greie å ha i verktøykassa. Om du har en du er spesielt fornøyd med, så send oss en mail — så kan det godt hende den dukker opp i denne serien!