Skip to main content
ANVISoftware Solutions
Lesson 7 of 18Intermediate18 min

Effects, and When You Do Not Need One

By the end of this lesson

Synchronise with the outside world without misusing effects for derived values.

An effect is code that runs after a render and reaches outside React. Setting the document title, starting a timer, subscribing to a browser event, opening a connection, measuring an element.

useEffect exists for one job: keeping something outside React in step with the state inside it. Read that as a restriction rather than a description. The majority of effects written by people learning React are not synchronising with anything external, and the code is shorter, faster and more correct without them.

So this lesson is in two halves. First, the cases where an effect is the wrong tool — that is where the bugs are. Then the dependency array and cleanup, for the cases where it is right.

You do not need an effect to:

  • Transform data for rendering. Filtering, sorting, totalling and formatting all belong in the render, calculated from state and props
  • Derive state from props. If a value can be computed from what you were given, compute it — do not copy it into state and then maintain the copy
  • Respond to a user event. Code that should run because someone clicked belongs in the click handler, where the reason for it is visible
  • Reset state when a prop changes. Give the component a different key and React discards the old instance, state included
  • Send data to a server on submit. That is an event, not a synchronisation — put it in the submit handler
  • Share state between components. Lift it to a common parent, or read it from context. An effect that copies one piece of state into another creates two sources of truth
Wrong: an effect that derives state from other state
TSX
"use client";

import { useEffect, useState } from "react";

function EmployeeDirectory({ employees }: { employees: Employee[] }) {
  const [term, setTerm] = useState("");
  const [matches, setMatches] = useState<Employee[]>([]);
  const [matchCount, setMatchCount] = useState(0);

  // Everything below is avoidable.
  useEffect(() => {
    const filtered = employees.filter((employee) =>
      employee.name.toLowerCase().includes(term.toLowerCase())
    );
    setMatches(filtered);
    setMatchCount(filtered.length);
  }, [employees, term]);

  return (
    <>
      <input value={term} onChange={(event) => setTerm(event.target.value)} />
      <p>{matchCount} results</p>
      <EmployeeTable employees={matches} />
    </>
  );
}
  • Follow one keystroke. React renders with the new term but the old matches, commits that to the screen, then runs the effect, which sets state and causes a second render. The user briefly sees results that do not match what they typed.
  • The first render of the component shows zero results regardless of the data, because matches starts as an empty array and the effect has not run yet.
  • matchCount is a third copy of information already present in matches.length. Two pieces of state that must agree, maintained by hand.
  • The effect list has to stay complete and correct forever. Add a department filter, forget to add it to the dependencies, and the results stop updating for that filter only — with no error anywhere.
Right: compute during render
TSX
"use client";

import { useState } from "react";

function EmployeeDirectory({ employees }: { employees: Employee[] }) {
  const [term, setTerm] = useState("");

  const matches = employees.filter((employee) =>
    employee.name.toLowerCase().includes(term.toLowerCase())
  );

  return (
    <>
      <input value={term} onChange={(event) => setTerm(event.target.value)} />
      <p>{matches.length} results</p>
      <EmployeeTable employees={matches} />
    </>
  );
}
  • One piece of state instead of three. One render per keystroke instead of two. No dependency array to keep in step, and no window in which the screen shows stale results.
  • The first render is correct, because matches is calculated before anything is displayed rather than after.
  • Adding a department filter means adding one piece of state and one clause to the filter. Nothing else can fall out of date, because nothing else is stored.
  • The test for whether you need an effect: could this value be calculated from what I already have? If yes, calculate it. Only reach for an effect when the answer involves something outside React entirely.
A legitimate effect: synchronising with a browser API
TSX
"use client";

import { useEffect, useState } from "react";

/** True while the viewport is at or above the given width. */
export function useIsWideScreen(minWidth = 1024) {
  const [isWide, setIsWide] = useState(false);

  useEffect(() => {
    const query = window.matchMedia("(min-width: " + minWidth + "px)");

    // Set the current value, because the query may already match.
    setIsWide(query.matches);

    function handleChange(event: MediaQueryListEvent) {
      setIsWide(event.matches);
    }

    query.addEventListener("change", handleChange);

    // Cleanup: remove the listener when the dependency changes
    // or the component is removed.
    return () => query.removeEventListener("change", handleChange);
  }, [minWidth]);

  return isWide;
}
  • This is genuine synchronisation. The viewport width lives in the browser, not in React, and nothing in state or props can tell you about it.
  • The effect sets the current value first. Without that, the state stays false until the width happens to cross the threshold, which may never occur.
  • The returned function is the cleanup. React runs it before the effect runs again, and once more when the component is removed. Without it, every remount adds another listener and the old ones keep calling setState on components that no longer exist.
  • minWidth is in the dependency array because the effect uses it. Change the prop and React cleans up the old listener and sets up one for the new query — which is what synchronisation means in practice.
  • Note that isWide starts as false rather than being read during render. Reading the viewport while rendering breaks server rendering, because there is no window on the server. Starting with a value and correcting it in the effect is the usual way to handle browser-only information.

The dependency array, precisely:

Omitted entirely
The effect runs after every render. Occasionally correct, usually a mistake, and a reliable way to build an infinite loop if the effect sets state.
An empty array
Run once when the component is added, clean up when it is removed. Correct for a subscription that depends on nothing, and wrong if the effect reads any prop or state — it will keep using the values from the first render.
One or more values
Run after the first render, then again whenever one of those values differs from last time. Cleanup runs before each re-run, so the old subscription is gone before the new one starts.
What belongs in it
Every reactive value the effect reads: props, state, and anything derived from them, including functions defined in the component. The ESLint rule computes this list correctly, and disagreeing with it is nearly always a sign the effect is structured wrongly.
How they are compared
By identity, the same as state. An object or array literal created during render is a new value every time, so an effect depending on it runs every render. Depend on the primitive fields you actually use, or move the object outside the component.

Summary

  • An effect keeps something outside React — the browser, the network, a library — in step with state
  • You do not need one to transform data, derive state from props, respond to an event, or reset state
  • Deriving state in an effect costs an extra render and shows stale content on the first one
  • Dependencies are compared by identity and must list every reactive value the effect reads
  • Anything that subscribes, connects or schedules needs a cleanup function

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Here is an effect from a course listing page. Remove it without changing what the user sees.

The component holds courses and selectedLevel in state, plus visibleCourses. An effect watches courses and selectedLevel and sets visibleCourses to the filtered result. A second effect sets isEmpty to visibleCourses.length === 0.

Show solution

Both effects and both extra pieces of state come out. visibleCourses is a filter over courses, and isEmpty is a comparison on its length. Neither reaches outside React, so neither is an effect's business.

The chained effects are the part worth noticing. isEmpty is computed from state that another effect sets, so the first render has courses loaded, no visible courses, and isEmpty false — a combination that is true of nothing. The user can see it if the renders are slow enough, and a test can see it always.

Deleting derived state usually deletes bugs you had not found yet. Here it also removes the possibility of the empty message disagreeing with the list underneath it, because there is now one calculation instead of two.

TSX
"use client";

import { useState } from "react";

export function CourseList({ courses }: { courses: Course[] }) {
  const [selectedLevel, setSelectedLevel] = useState("all");

  const visibleCourses =
    selectedLevel === "all"
      ? courses
      : courses.filter((course) => course.level === selectedLevel);

  return (
    <div>
      <LevelFilter value={selectedLevel} onChange={setSelectedLevel} />
      {visibleCourses.length === 0 ? (
        <p>No courses at this level yet.</p>
      ) : (
        <CourseGrid courses={visibleCourses} />
      )}
    </div>
  );
}

Think about it

Think about it

For each of these, decide whether an effect is the right tool: (a) showing the number of selected employees, (b) setting the browser tab title to the current department, (c) sending a filter change to an analytics service, (d) clearing the search box when the user switches department, (e) focusing the search box when a dialog opens.

Show solution

(a) No. The count is selectedIds.length, computed during render.

(b) Yes. The document title belongs to the browser, not to React, so keeping it in step with state is exactly what an effect is for. Note that in a Next.js app the metadata API is the better tool for a page title — reach for the effect only for a title that changes with client-side state.

(c) No, in the normal case. The filter changed because the user did something, so the analytics call belongs in that handler, where the reason is visible. An effect would also fire for programmatic changes you did not intend to report, such as restoring filters from a URL.

(d) No. This is the reset case. Either handle it in the department change handler, which is honest about the cause, or give the search box a key that includes the department so React replaces the component and its state. An effect watching department to clear term is a loop waiting to be written.

(e) Yes. Focus is a property of the DOM, and moving it is a side effect by definition. The accessibility lesson covers doing it properly, including returning focus when the dialog closes.

The pattern in the answers: ask what owns the thing being changed. If React owns it, use render or a handler. If the browser, the network or a third-party library owns it, an effect is the right place.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A component filters employees in an effect and stores the result in state. What does the user see on the first render?
What does the function returned from an effect do?
An effect sets state, and that state is also in its dependency array, causing an endless loop. What is the right response?

Saved in this browser only.