Skip to main content
React · lesson 10 of 22
React intermediate Lesson 10 of 22

Custom 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

react hooks custom-hooks

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.

Frequently Asked Questions

What makes a function a custom hook rather than just a helper function?
Nothing beyond convention and one real constraint: its name starts with use, and it calls other hooks (useState, useEffect, and so on) inside it. The use prefix is what tells both React's linter and other developers that the function follows the Rules of Hooks and can only be called from a component or another hook.
How do you test a custom hook if you can't call it directly?
Render a small host component that calls the hook and exposes what it needs to check — text in the DOM, a button wired to one of the hook's returned functions. Testing Library then interacts with that host component exactly like any other component, which also tests the hook the way it's actually used.
Does a custom hook share state between every component that uses it?
No. Calling useCounter() in two different components creates two completely independent pieces of state, each with its own useState call — a custom hook is a reusable recipe for stateful logic, not a shared store. Use context (the previous lesson) for state that multiple components need to share.
Can a custom hook have an effect with cleanup, like useEffect does directly?
Yes — a custom hook is just a function that can contain any hook, including useEffect with a cleanup function. The cleanup rules from the effects lesson apply exactly the same way: React runs it before the effect re-runs for a changed dependency, and on unmount.