Back to all notes

Effect Separation: One Effect per Concern

Split effects so each handles a single job with its own dependencies.

2 min read

I had one useEffect that set the document title from name and logged every count change. The dependency array was [name, count], so typing in the name field fired the count log too.

Two Jobs, One Dependency Array

import { useState, useEffect } from "react";

function App() {
  const [name, setName] = useState("John");
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Hello ${name}`;
    console.log(`Count changed to: ${count}`);
  }, [name, count]);

  return (
    <div>
      <h1>Effect Separation Pattern</h1>
      <div>
        <label>
          Name:{" "}
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
        </label>
      </div>
      <div>
        <p>Count: {count}</p>
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </div>
    </div>
  );
}

export default App;

Neither job cares about the other’s data, but sharing an effect ties their lifecycles together and muddies the dependency array. Every re-run does both pieces of work whether it needed to or not.

Split Them

The fix is to give each job its own effect, with only the dependencies it actually reads.

import { useState, useEffect } from "react";

function App() {
  const [name, setName] = useState("John");
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Hello ${name}`;
  }, [name]);

  useEffect(() => {
    console.log(`Count changed to: ${count}`);
  }, [count]);

  return (
    <div>
      <h1>Effect Separation Pattern</h1>
      <div>
        <label>
          Name:{" "}
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
        </label>
      </div>
      <div>
        <p>Count: {count}</p>
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </div>
    </div>
  );
}

export default App;

Now the title effect re-runs only when name changes, and the logger only when count does. Each dependency array finally describes its own effect.

One effect, one reason to re-run. When an effect keeps growing, I pull logic out into helpers and keep the body small. And before writing an effect at all, I check whether the value could be derived during render or moved into an event handler instead.