React · lesson 10 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
5Custom Hooks
Extract reusable stateful logic into a custom hook, test it through a tiny host component, and give one an effect with real cleanup.
By SkillByExample · Updated
A custom hook is a function, named starting with use, that calls other hooks inside it — a way to package stateful logic so more than one component can reuse it without copying the useState calls themselves.
Extracting Counter Logic
Three components each needing their own increment/decrement/reset counter would otherwise repeat the same three lines of useState and setters.
// src/custom-hooks/useCounter.ts
import { useState } from "react";
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
// src/custom-hooks/CounterDisplay.tsx
import { useCounter } from "./useCounter.js";
export function CounterDisplay({ initial }) {
const { count, increment, decrement, reset } = useCounter(initial);
return (
<div>
<p>Value: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>reset</button>
</div>
);
}
// src/custom-hooks/CounterDisplay.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CounterDisplay } from "./CounterDisplay.js";
it("shares increment/decrement/reset logic through useCounter", async () => {
const user = userEvent.setup();
render(<CounterDisplay initial={5} />);
await user.click(screen.getByRole("button", { name: "+" }));
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Value: 7")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "-" }));
expect(screen.getByText("Value: 6")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "reset" }));
expect(screen.getByText("Value: 5")).toBeInTheDocument();
});
$ npx vitest run custom-hooks/CounterDisplay.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
✓ src/custom-hooks/CounterDisplay.test.tsx > shares increment/decrement/reset logic through useCounter 226ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 06:39:36
Duration 29.22s (environment 69%, setup 19%, import 10%, transform 1%, tests 1%)
The test never calls useCounter itself — it can’t, outside a component — so it renders CounterDisplay and drives it through the DOM exactly like a real user would, which exercises the hook’s logic along the way.
An Effect Inside a Custom Hook
A custom hook can contain useEffect, cleanup included, exactly as if it were written directly in the component.
// src/custom-hooks/useDocumentTitle.ts
import { useEffect } from "react";
export function useDocumentTitle(title) {
useEffect(() => {
const previous = document.title;
document.title = title;
return () => {
document.title = previous;
};
}, [title]);
}
// src/custom-hooks/PageHeader.tsx
import { useDocumentTitle } from "./useDocumentTitle.js";
export function PageHeader({ title }) {
useDocumentTitle(title);
return <h1>{title}</h1>;
}
// src/custom-hooks/PageHeader.test.tsx
import { it, expect } from "vitest";
import { render } from "@testing-library/react";
import { PageHeader } from "./PageHeader.js";
it("sets document.title while mounted, then restores it on unmount", () => {
document.title = "App";
const { unmount } = render(<PageHeader title="Inbox" />);
expect(document.title).toBe("Inbox");
unmount();
expect(document.title).toBe("App");
});
$ npx vitest run custom-hooks/PageHeader.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
✓ src/custom-hooks/PageHeader.test.tsx > sets document.title while mounted, then restores it on unmount 21ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 06:40:12
Duration 23.35s (environment 75%, setup 23%, transform 1%)
PageHeader doesn’t know anything about saving or restoring a previous title — that’s entirely inside useDocumentTitle, which document.title proves actually ran, with no DOM assertion on the <h1> itself needed to show the hook worked.
Practice
1. Add a step parameter to useCounter so increment/decrement move by more than one.
Solution
// src/custom-hooks/practice/useCounterStep.ts
import { useState } from "react";
export function useCounterStep(initial = 0, step = 1) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + step);
const decrement = () => setCount((c) => c - step);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
// src/custom-hooks/practice/StepDisplay.tsx
import { useCounterStep } from "./useCounterStep.js";
export function StepDisplay() {
const { count, increment, decrement, reset } = useCounterStep(0, 5);
return (
<div>
<p>Value: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>reset</button>
</div>
);
}
// src/custom-hooks/practice/StepDisplay.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StepDisplay } from "./StepDisplay.js";
it("increments by the custom step instead of always by one", async () => {
const user = userEvent.setup();
render(<StepDisplay />);
await user.click(screen.getByRole("button", { name: "+" }));
await user.click(screen.getByRole("button", { name: "+" }));
console.log("screen text after two clicks:", screen.getByText(/Value:/).textContent);
expect(screen.getByText("Value: 10")).toBeInTheDocument();
});
$ npx vitest run custom-hooks/practice/StepDisplay.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
stdout | src/custom-hooks/practice/StepDisplay.test.tsx > increments by the custom step instead of always by one
screen text after two clicks: Value: 10
✓ src/custom-hooks/practice/StepDisplay.test.tsx > increments by the custom step instead of always by one 212ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 06:40:40
Duration 22.57s (environment 65%, setup 21%, import 11%, transform 2%, tests 1%)
useCounterStep(0, 5) is the same hook shape as before, just with one more closed-over parameter — two clicks land on 10, visible directly in the logged screen text, not just implied by the assertion passing.
2. Write a useLocalValue hook that persists its value to window.localStorage, and confirm a fresh mount reads the saved value back.
Solution
// src/custom-hooks/practice/useLocalValue.ts
import { useState } from "react";
export function useLocalValue(key, initial) {
const [value, setValue] = useState(() => {
const stored = window.localStorage.getItem(key);
return stored !== null ? stored : initial;
});
function update(next) {
setValue(next);
window.localStorage.setItem(key, next);
}
return [value, update];
}
// src/custom-hooks/practice/NameField.tsx
import { useLocalValue } from "./useLocalValue.js";
export function NameField() {
const [name, setName] = useLocalValue("name", "");
return <input value={name} onChange={(e) => setName(e.target.value)} placeholder="name" />;
}
// src/custom-hooks/practice/NameField.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NameField } from "./NameField.js";
it("persists what's typed to localStorage, and a fresh mount reads it back", async () => {
window.localStorage.clear();
const user = userEvent.setup();
const { unmount } = render(<NameField />);
await user.type(screen.getByPlaceholderText("name"), "Ada");
expect(screen.getByPlaceholderText("name")).toHaveValue("Ada");
unmount();
render(<NameField />);
expect(screen.getByPlaceholderText("name")).toHaveValue("Ada");
});
$ npx vitest run custom-hooks/practice/NameField.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
✓ src/custom-hooks/practice/NameField.test.tsx > persists what's typed to localStorage, and a fresh mount reads it back 152ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 06:41:08
Duration 22.27s (environment 69%, setup 20%, import 9%, transform 1%, tests 1%)
Typing into the field writes to localStorage on every change; unmounting and mounting a brand-new NameField reads the same key back in its useState initializer, so the input starts already filled in.
3. Build a useToggle hook and use it in a Collapsible component that shows or hides its children.
Solution
// src/custom-hooks/practice/useToggle.ts
import { useState } from "react";
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((o) => !o);
return [on, toggle];
}
// src/custom-hooks/practice/Collapsible.tsx
import { useToggle } from "./useToggle.js";
export function Collapsible({ children }) {
const [open, toggle] = useToggle(false);
return (
<div>
<button onClick={toggle}>{open ? "Hide" : "Show"}</button>
{open && <div>{children}</div>}
</div>
);
}
// src/custom-hooks/practice/Collapsible.test.tsx
import { it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Collapsible } from "./Collapsible.js";
it("toggles content visibility, flipping the button label each time", async () => {
const user = userEvent.setup();
render(<Collapsible>secret text</Collapsible>);
expect(screen.queryByText("secret text")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show" })).toBeInTheDocument();
await user.click(screen.getByRole("button"));
expect(screen.getByText("secret text")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Hide" })).toBeInTheDocument();
await user.click(screen.getByRole("button"));
expect(screen.queryByText("secret text")).not.toBeInTheDocument();
});
$ npx vitest run custom-hooks/practice/Collapsible.test.tsx --reporter=verbose
RUN v5.0.5 /root/react-app
✓ src/custom-hooks/practice/Collapsible.test.tsx > toggles content visibility, flipping the button label each time 206ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 06:41:35
Duration 23.69s (environment 68%, setup 21%, import 8%, transform 1%, tests 1%)
The hook only knows about a boolean and a function to flip it — Collapsible decides what “on” means (content shown, button labeled “Hide”), which is what makes useToggle reusable for anything with two states.