useEffect

Overview

useEffect lets you specify side effects that should be triggered by the rendering of a component, rather than by a particular user-triggered event (like a button click).

  • ex. setting up a connection to a remote server happens once the component renders— not when a user performs some action.

You can think of useEffect as "attaching" a piece of behavior to the render output.

The purpose of useEffect is to allow us use an escape hatch from React and synchronize with some external system like a non-React widget, network, or the browser DOM. If there is no external system involved, then we shouldn't need a useEffect

Relationship to closures

When you define a function inside the useEffect hook, that function forms a closure over the variables in the component's scope at the time the effect was created.

  • ex. In the following example, the handleClick function forms a closure over the count variable. This means that when handleClick is invoked, it will capture the value of count from the outer scope where the effect was created.
function MyComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const handleClick = () => {
      setCount(count + 1); // This will capture the count value from the outer scope
    };

    document.addEventListener('click', handleClick);

    return () => {
      document.removeEventListener('click', handleClick);
    };
  }, [count]);

  // Rest of the component...
}

Effects from each render are isolated from each other. This is due to the closure.

When the effect gets run

useEffect runs after the JSX has been reconciled and the DOM has been updated.

  • contrast this will all other non-JSX code in a component body which gets run while the component is rendering.
    • For this reason, we must pay especially close attention to component body code that would attempt to modify any DOM elements that are generated by the component's JSX.
    • This is the value of useEffect. It allows us to wait until a component has finished rendering before we attempt to manipulate any DOM elements
    • ex. if our App component had a child VideoPlayer component, we would not be able to attach a ref to the <video> tag and expect to be able to just call ref.current.play() on it. We would have to wrap that code in a useEffect, which would essentially be saying "don't try to access this ref until the component has finished rendering all its JSX (see example here)

Keep in mind that useEffect is typically used to "step out" of your React code and synchronize with some external system

  • ex. "external system" might mean browser APIs, third-party widgets, network

We should also add a cleanup function (ie. the return code) if needed. Some Effects need to specify how to stop, undo, or clean up whatever they were doing.

  • ex. "connect" needs "disconnect", "subscribe" needs "unsubscribe", and "fetch" needs either "cancel” or "ignore".

React always cleans up the previous render’s Effect before the next render’s Effect.

  • that is, if we are in a state where useEffect needs to run again (e.g. a value in the dependency changed), the cleanup will always get run before the next useEffect gets called.

In development mode, React remounts the component once to verify that you’ve implemented cleanup well.

  • that is, in a useEffect, the main body will get run once, then the cleanup gets run, then the main body will get run again.

Dependency array

By default, useEffect will run after every render. However, most of the time, it should only re-run when needed rather than after every render. This can be controlled with the dependency array.

  • ex. a fade-in animation should only trigger when a component appears. Connecting and disconnecting to a chat room should only happen when the component appears and disappears, or when the chat room changes.

The question is not "when does this effect run", the question is "with which state does this effect synchronize with:"

  • useEffect(fn) - all state
  • useEffect(fn, []) - no state
  • useEffect(fn, [these, states])

If one of the variables in the dependency array changes, useEffect runs again. If the array is empty the hook doesn't run when updating the component at all, because it doesn't have to watch any variables.

You only need to include a variable in the dependency array if that variable changes due to a re-render.

  • ex. if we define a variable that is defined outside the component, then it wouldn't change between renders. For this reason, it doesn't have to be a part of the dependency array.
  • Props, state, and other values declared inside the component are reactive because they’re calculated during rendering and participate in the React data flow. Therefore, they must be included in the dependency array.
    • by extension, any variable that is declared that uses any reactive variable is also reactive
  • ref.current is mutable and since changing it doesn’t trigger a re-render, it’s not a reactive value

If you have no dependency array and are getting infinite loops, see if there are functions being defined each time in the component. It might be a simple fix to memoize them with useCallback

  • simply adding an empty dependency array is a bandaid solution and is not really addressing the root of the problem.

The dependency array is used to specify which values from the component's scope should be captured by the closure.

  • If a variable used in the useEffect is not listed in the dependency array and changes between renders, the effect will still capture the original value.
    • spec: verify this

Shortcoming of useEffect

Compared to class component lifecycle methods, the shortcoming of useEffect is that while we can set new state, we are unable to access current state (because of stale closure)

useEffect(() => {
    const intervalId = setInterval(() => {
        setCount(count + 1)
    }, 1000)
    return () => clearInterval(intervalId)
}, [])

In this example, count is always pointing to the previous reference.

  • we can work around this shortcoming with refs. Essentially, because refs exist between renders, that value is never lost between mounts. We simply take the value from the ref, and update the state with that value:
useEffect(() => {
    const intervalId = setInterval(() => {
        countRef.current = countRef.current + 1
        setCount(countRef.current)
    }, 1000)
    return () => clearInterval(intervalId)
}, [])

this is mutable state, and the problem with mutable state is that it is always up to date. The good thing about hooks in react is that there is no this to retrieve values from. State always stays the same within a given render of a component.

useEffect that depends on a function

if your effect depends on a function, storing that function in a ref is a useful pattern. Like this:

const funcRef = useRef(func)

useEffect(() => {
    funcRef.current = func
})

useEffect(() => {
    // do some stuff and then call
    funcRef.current()
}, [/* ... */])

Using multiple useEffects

Each Effect should represent a separate synchronization process, and we should resist adding unrelated logic to our Effect only because this logic needs to run at the same time as an Effect we already wrote.

  • ex. say you want to send an analytics event when the user visits the room. You already have an Effect that depends on roomId, so you might feel tempted to add the analytics call there:
function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId);
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

But imagine you later add another dependency to this Effect that needs to re-establish the connection. If this Effect re-synchronizes, it will also call logVisit(roomId) for the same room, which you did not intend. Logging the visit is a separate process from connecting. Write them as two separate Effects:

function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId);
  }, [roomId]);

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    // ...
  }, [roomId]);
  // ...
}

In the above example, deleting one Effect wouldn’t break the other Effect’s logic. This is a good indication that they synchronize different things, and so it made sense to split them up. On the other hand, if you split up a cohesive piece of logic into separate Effects, the code may look “cleaner” but will be more difficult to maintain. This is why you should think whether the processes are same or separate, not whether the code looks cleaner.

UE Resources