React · lesson 7 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5You Might Not Need an Effect
Measure the extra render an effect adds when it only syncs derived state from props, then remove both the effect and the state entirely.
By SkillByExample · Updated
A React render is a reasonable place to compute most things. An effect that only recomputes a value already derivable from props or state adds a render and a lag that a direct calculation doesn’t have.
Syncing State with an Effect, Measured
Storing a derived value in its own state and syncing it with an effect costs an extra render every time the inputs change.
// src/not-an-effect/FullNameEffect.tsx
import { useEffect, useState } from "react";
export function FullNameEffect({ first, last }) {
const [fullName, setFullName] = useState("");
useEffect(() => {
console.log("effect: recomputing full name");
setFullName(`${first} ${last}`);
}, [first, last]);
console.log("render, fullName =", JSON.stringify(fullName));
return <p>{fullName}</p>;
}
// src/not-an-effect/FullNameEffect.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { FullNameEffect } from "./FullNameEffect.js";
it("renders empty first, then an effect fills it in on the next render", () => {
render(<FullNameEffect first="Ada" last="Lovelace" />);
expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
});
$ npx vitest run not-an-effect/FullNameEffect.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/not-an-effect/FullNameEffect.test.tsx > renders empty first, then an effect fills it in on the next render
render, fullName = ""
effect: recomputing full name
render, fullName = "Ada Lovelace"
✓ src/not-an-effect/FullNameEffect.test.tsx > renders empty first, then an effect fills it in on the next render 29ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:46:01
Duration 35.79s (environment 77%, setup 22%, transform 1%)
Three log lines for what should be one piece of information: the first render shows fullName = "", because useState("")’s initial value hasn’t been touched yet; only after that render commits does the effect run, compute the real name, and call setFullName, which triggers a second render that finally shows "Ada Lovelace". The test still passes — getByText just waits for whatever’s on screen when it runs — but a reader watching the page would see it flash empty first.
Computing It During Render Instead
fullName doesn’t depend on anything an effect can reach that render can’t — so skip the state and the effect.
// src/not-an-effect/FullNameDerived.tsx
export function FullNameDerived({ first, last }) {
const fullName = `${first} ${last}`;
console.log("render, fullName =", JSON.stringify(fullName));
return <p>{fullName}</p>;
}
// src/not-an-effect/FullNameDerived.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { FullNameDerived } from "./FullNameDerived.js";
it("renders the full name immediately, in a single render, no effect", () => {
render(<FullNameDerived first="Ada" last="Lovelace" />);
expect(screen.getByText("Ada Lovelace")).toBeInTheDocument();
});
$ npx vitest run not-an-effect/FullNameDerived.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/not-an-effect/FullNameDerived.test.tsx > renders the full name immediately, in a single render, no effect
render, fullName = "Ada Lovelace"
✓ src/not-an-effect/FullNameDerived.test.tsx > renders the full name immediately, in a single render, no effect 27ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:46:40
Duration 30.91s (environment 71%, setup 28%, transform 1%)
One log line. The correct value is there on the very first render, because it was never missing — fullName is a plain local variable computed from first and last every time the component runs, with no state and nothing for an effect to synchronize.
Practice
1. Compare a “visible todos” list kept in its own state and synced with an effect against computing it directly, across the same todos/filter props.
Solution
// src/not-an-effect/practice/VisibleTodosEffect.tsx
import { useEffect, useState } from "react";
export let effectRuns = 0;
export function VisibleTodosEffect({ todos, filter }) {
const [visible, setVisible] = useState([]);
useEffect(() => {
effectRuns++;
setVisible(todos.filter((t) => t.includes(filter)));
}, [todos, filter]);
return <p>Visible: {visible.join(", ")}</p>;
}
// src/not-an-effect/practice/VisibleTodosDerived.tsx
export function VisibleTodosDerived({ todos, filter }) {
const visible = todos.filter((t) => t.includes(filter));
return <p>Visible: {visible.join(", ")}</p>;
}
// src/not-an-effect/practice/VisibleTodos.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { VisibleTodosEffect, effectRuns } from "./VisibleTodosEffect.js";
import { VisibleTodosDerived } from "./VisibleTodosDerived.js";
const todos = ["buy milk", "walk dog", "buy bread"];
it("the effect version renders an empty list first, then fills in after an extra render", () => {
const runsBeforeMount = effectRuns;
render(<VisibleTodosEffect todos={todos} filter="buy" />);
console.log("effectRuns:", runsBeforeMount, "->", effectRuns);
expect(effectRuns).toBe(runsBeforeMount + 1);
expect(screen.getByText("Visible: buy milk, buy bread")).toBeInTheDocument();
});
it("the derived version is correct on the very first render, no effect involved", () => {
render(<VisibleTodosDerived todos={todos} filter="buy" />);
expect(screen.getByText("Visible: buy milk, buy bread")).toBeInTheDocument();
});
$ npx vitest run not-an-effect/practice/VisibleTodos.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/not-an-effect/practice/VisibleTodos.test.tsx > the effect version renders an empty list first, then fills in after an extra render
effectRuns: 0 -> 1
✓ src/not-an-effect/practice/VisibleTodos.test.tsx > the effect version renders an empty list first, then fills in after an extra render 24ms
✓ src/not-an-effect/practice/VisibleTodos.test.tsx > the derived version is correct on the very first render, no effect involved 3ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 17:50:11
Duration 30.66s (environment 79%, setup 20%, transform 1%)
Same filtering logic, same final markup — the effect version needs an effect run (effectRuns goes from 0 to 1) and the extra render it triggers to get there.
2. Reset a form’s local state when its userId prop changes, using key instead of an effect.
Solution
// src/not-an-effect/practice/ProfileForm.tsx
import { useState } from "react";
export function ProfileForm({ userId, initialBio }) {
const [bio, setBio] = useState(initialBio);
return (
<div>
<p>User: {userId}</p>
<textarea value={bio} onChange={(e) => setBio(e.target.value)} />
</div>
);
}
// src/not-an-effect/practice/ProfileForm.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProfileForm } from "./ProfileForm.js";
it("without a key, editing the textarea then switching users keeps the stale edit", async () => {
const user = userEvent.setup();
const { rerender } = render(<ProfileForm userId={1} initialBio="Ada's bio" />);
await user.clear(screen.getByRole("textbox"));
await user.type(screen.getByRole("textbox"), "edited!");
rerender(<ProfileForm userId={2} initialBio="Grace's bio" />);
expect(screen.getByRole("textbox")).toHaveValue("edited!");
});
it("with key={userId}, switching users remounts the form and resets the textarea", async () => {
const user = userEvent.setup();
const { rerender } = render(<ProfileForm key={1} userId={1} initialBio="Ada's bio" />);
await user.clear(screen.getByRole("textbox"));
await user.type(screen.getByRole("textbox"), "edited!");
rerender(<ProfileForm key={2} userId={2} initialBio="Grace's bio" />);
expect(screen.getByRole("textbox")).toHaveValue("Grace's bio");
});
$ npx vitest run not-an-effect/practice/ProfileForm.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
✓ src/not-an-effect/practice/ProfileForm.test.tsx > without a key, editing the textarea then switching users keeps the stale edit 174ms
✓ src/not-an-effect/practice/ProfileForm.test.tsx > with key={userId}, switching users remounts the form and resets the textarea 37ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 17:47:48
Duration 33.97s (environment 65%, setup 23%, import 10%, transform 1%, tests 1%)
Without a key, React sees the same component type in the same position and keeps the old bio state — the edited text from Ada’s form leaks into Grace’s. Rendering <ProfileForm key={userId} ... /> makes React treat each userId as a distinct instance: a changed key discards the old instance (and its state) and mounts a brand new one, so bio starts fresh from initialBio every time — no effect needed to “reset” anything.
3. Count how many times each version from the first example logs a render for the exact same prop change.
Solution
import { it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { FullNameEffect } from "../FullNameEffect.js";
import { FullNameDerived } from "../FullNameDerived.js";
it("the effect version logs two renders for one prop change", () => {
const spy = vi.spyOn(console, "log");
const { rerender } = render(<FullNameEffect first="Ada" last="Lovelace" />);
spy.mockClear();
rerender(<FullNameEffect first="Grace" last="Hopper" />);
const renderLogs = spy.mock.calls.filter((call) => call[0] === "render, fullName =");
expect(renderLogs).toHaveLength(2);
spy.mockRestore();
});
it("the derived version logs exactly one render for the same prop change", () => {
const spy = vi.spyOn(console, "log");
const { rerender } = render(<FullNameDerived first="Ada" last="Lovelace" />);
spy.mockClear();
rerender(<FullNameDerived first="Grace" last="Hopper" />);
const renderLogs = spy.mock.calls.filter((call) => call[0] === "render, fullName =");
expect(renderLogs).toHaveLength(1);
spy.mockRestore();
});
$ npx vitest run not-an-effect/practice/FullNameRerenderCount.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/not-an-effect/practice/FullNameRerenderCount.test.tsx > the effect version logs two renders for one prop change
render, fullName = ""
effect: recomputing full name
render, fullName = "Ada Lovelace"
render, fullName = "Ada Lovelace"
effect: recomputing full name
render, fullName = "Grace Hopper"
stdout | src/not-an-effect/practice/FullNameRerenderCount.test.tsx > the derived version logs exactly one render for the same prop change
render, fullName = "Ada Lovelace"
render, fullName = "Grace Hopper"
✓ src/not-an-effect/practice/FullNameRerenderCount.test.tsx > the effect version logs two renders for one prop change 26ms
✓ src/not-an-effect/practice/FullNameRerenderCount.test.tsx > the derived version logs exactly one render for the same prop change 4ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 17:48:25
Duration 29.47s (environment 75%, setup 24%, transform 1%)
Switching from Ada to Grace costs the effect version two renders every time, not just on mount — the pattern doesn’t get cheaper once the component has settled.