Side effects, dependencies and cleanup

February 25, 2022 · 6 min read

My login app forgot who I was every time I refreshed the page. Fixing that was my first real use of useEffect, and my first attempt at it produced an infinite loop. This note is about why.

React has one job

React turns state and props into UI, and re-runs components when those change. That's the whole job. Anything else your app does is a side effect: sending an HTTP request, writing to localStorage, starting a timer. You might do those things so you can draw something afterwards, but React doesn't care about the request itself.

The trap is that a component function runs again on every re-render. If I send a request in the body, I send it on every render. If the response sets state, that triggers another render, which sends another request. That is the infinite loop I hit.

useEffect takes a function and a list

useEffect(() => {
  // the side effect
}, [dependencies]);

React runs the function after the component has rendered, and only when something in the list changed since the last run. An empty list never changes, so the effect runs once, on the first render.

That's exactly what the login check needed. I save a flag when the user logs in, and read it back once when the app starts:

useEffect(() => {
  if (localStorage.getItem("isLoggedIn") === "1") {
    setIsLoggedIn(true);
  }
}, []);

My first version had the if straight in the component body. It called setIsLoggedIn, which re-rendered, which ran the check again. Moving it into an effect with [] fixed it.

Dependencies as a trigger

I had the same validity check copied into both the email and password handlers. With an effect I could write it once and let it react to whichever input changed:

useEffect(() => {
  setFormIsValid(
    enteredEmail.includes("@") && enteredPassword.trim().length > 6
  );
}, [enteredEmail, enteredPassword]);

This felt wrong at first, because there's no request or storage here. What helped was thinking of an effect as code that runs in response to something. Here the something is the user typing. (I'd do this differently now, see the box above. Validity is just a value you can compute while rendering.)

Cleanup and debouncing

That effect runs on every keystroke. Cheap here, but imagine checking with a server whether a username is taken. I don't want a request per letter. I want to wait until the user pauses.

The trick is to start a timer and keep hold of it. When the next keystroke comes, throw the old timer away. Only the last one survives long enough to fire.

KEYSTROKESabcdclearedclearedcleared500ms of silencevalidatetime →
Every keystroke clears the previous timer. Only the timer after the last keystroke gets to finish.

An effect can return a function, and React calls it as cleanup. That's where the old timer gets cleared:

useEffect(() => {
  const timer = setTimeout(() => {
    setFormIsValid(
      enteredEmail.includes("@") && enteredPassword.trim().length > 6
    );
  }, 500);

  return () => clearTimeout(timer);
}, [enteredEmail, enteredPassword]);

The cleanup runs before the effect runs again, so each keystroke cancels the previous timer first. It never runs before the very first run, since there's nothing to clean yet. It also runs when the component unmounts, so a timer can't fire for something that's gone.

What stuck with me: an effect is for things that happen because of a render, and whatever it starts, it should know how to stop.