React-hook: Fiks en minnelekkasje i React 🚿

Kaptein Krok: Lær å takle feilen "Can’t perform a React state update on an unmounted component".

📸: Privat / Unsplash
📸: Privat / Unsplash Vis mer

iHar du noensinne prøvd å sette state asynkront i React, men fått følgende strofe rett i trynet?

Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

Det har jeg også. Det skjer typisk når du har trigget litt henting av data, og navigert videre før du fikk svar. Og det kan – om du ikke er forsiktig – føre til en minnelekkasje, som kan gjøre appen din tregere og tregere etter hvert som den kjører.

Minnelekkasje? I MIN APP?!?

Her er et eksempel på kode som kan føre til en slik situasjon:

Om Eksempel ikke lenger er “mounted” (rendret på skjermen) når setData blir kalt, vil du få en sinna melding i consolen din.

const Eksempel = () => {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(newData => setData(newData);
    }, []);
  return <DataDisplayer2000 data={data} />;
}

Fiks minnelekkasjen min 😱

For å fikse dette, må vi sjekke om komponenten er mounted, og spore dette på et vis. Det kan vi gjøre ved å bruke to innebygde hooks useRef og useEffect:

const Eksempel = () => {
  const [data, setData] = useState(null);
  const isMounted = useRef(true);
  useEffect(() => {
    return () => {
      isMounted.current = false;
    }
  }, []);
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(newData => {
        if (isMounted.current) {
          setData(newData);
        }
      });
    }, []);
  return <DataDisplayer2000 data={data} />;
}

Her ble det fort mye kode som ikke hadde så mye med den originale koden vår å gjøre, så her kan vi refaktorere litt. Først – la oss lage en custom hook som forteller oss om komponenten er mounted eller ei.

const useIsMounted = () => {
  const isMountedRef = useRef(true);
  useEffect(() => {
    return () => {
      isMountedRef.current = false;
    }
  }, []);
  return isMountedRef.current
};

Her har vi flyttet all logikken vår inn i useIsMounted, som rett og slett returnerer en boolean som indikerer det vi lurte på. La oss ta den i bruk i eksemplet vårt!


const Eksempel = () => {
  const [data, setData] = useState(null);
  const isMounted = useRef(true);
  useEffect(() => {
    return () => {
      isMounted.current = false;
    }
  }, []);
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(newData => {
        if (isMounted.current) {
          setData(newData);
        }
      });
    }, []);
  return <DataDisplayer2000 data={data} />;
}

Ah, straks bedre! Eller…?

En bug 🐛

Her har vi faktisk innført en ganske lei bug — isMounted vil alltid være true! Det er fordi hooken vår aldri blir kalt på ny, som gjør at isMounted som brukes inni useEffect vil være true, selv om ref-en vår er false.

Dette kan vi heldigvis rydde opp i med å returnere en funksjon som returnerer ref-verdien istedenfor verdien direkte. Med andre ord — la oss endre hooken vår til dette:

const useIsMounted = () => {
  const isMountedRef = useRef(true);
  useEffect(() => {
    return () => {
      isMountedRef.current = false;
    }
  }, []);
  // se her - nå returnerer vi en funksjon!
  return () => isMountedRef.current
};

Bruken må også oppdateres — nå må vi kalle isMounted for å få den nyeste verdien:

const Eksempel = () => {
  const [data, setData] = useState(null);
  const isMounted = useIsMounted();
useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(newData => {
        if (isMounted()) {
          setData(newData);
        }
      });
    }, []);
  return <DataDisplayer2000 data={data} />;
}

Og med dette, så fungerer det i alle fall slik det skal. Tusen takk til Erlend Åmdal som pekte ut denne litt flaue feilen for meg.

Men kanskje vi kan gjøre ting enda enklere? Kanskje man rett og slett kan bygge denne funksjonaliteten inn i useState , og returnere isMounted som en tredje parameter? 🤔

Hils på useMountedState 🐴

La oss prøve oss på en liten implementasjon:

const useMountedState = (defaultValue) => {
  const isMounted = useIsMounted();
  const [state, setState] = useState(defaultValue);
  const defensiveSetState = useCallback((newValue) => {
    if (isMounted()) {
      setState(newValue);
    }
  }}, [])
  return [state, defensiveSetState, isMounted];
};

useMountedState er egentlig bare en veldig tynn wrapper av den innebygde useState hooken – hvor man unngår å kalle setState om man ikke er mounted.

Dette vil fjerne advarselen, men det kan fortsatt være situasjoner der man vil gjøre nye kall, dataprosessering osv. etter det første kallet – og da vil man jo fortsatt ha denne minnelekkasjen. Heldigvis kan vi nå skrive kode for å unngå å fortsette om den tredje verdien som returneres – isMounted – er false.

La oss ta en siste refaktorering av eksemplet vårt:

const Eksempel = () => {
  const [data, setData, isMounted] = useMountedState(null);
  useEffect(() => {
    fetch('/api/data')
      .then(res => isMounted() && res.json())
      .then(newData => {
        setData(newData);
      });
    }, []);
  return <DataDisplayer2000 data={data} />;
}

Nå ser koden like enkel ut som den gjorde i utgangspunktet – men helt minnelekkasje-fri. Vi hopper til og med over JSON-parsingen om ikke komponenten er på skjermen lenger!

Merk at vi har utelatt isMounted fra useEffect sin dependency array. Den vil ikke trigge en re-render, siden isMounted er en useRef – verdi, sånn egentlig.

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!