React Interview Prep
Testing and Interview Scenarios

Testing Hooks & Async Code

The Skills That Separate Senior from Junior

LinkedIn Hook

Every React developer learns to test buttons and text on screen. But the moment an interviewer says "test this custom hook" or "write a test for a component that fetches data on mount" — most candidates freeze.

Testing hooks and async code is where interviews get real. You need to understand renderHook, you need to know why act() warnings appear and how to fix them, you need to mock API calls cleanly, and you need to test loading and error states without flaky timing issues.

The problem is not that these concepts are hard. The problem is that most tutorials skip them. They show you how to test a counter button and call it a day. Nobody walks you through testing a useDebounce hook in isolation, or mocking a failed network request with MSW, or explaining why your test logs that angry act() warning even though your code works fine.

In this lesson, I break down every piece: how renderHook works and when to use it, how to test useEffect-driven async operations, how to mock API calls with both jest.mock and MSW, how to assert loading and error states, and exactly what act() does and why React yells at you when you forget it.

If your tests only cover "renders without crashing" and "button click changes text" — this lesson takes you to the level interviewers actually expect.

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #Testing #ReactTestingLibrary #WebDevelopment #CodingInterview #100DaysOfCode


Testing Hooks & Async Code thumbnail


What You'll Learn

  • How to test custom hooks in isolation using renderHook from React Testing Library
  • How to test useEffect-driven async operations without flaky timing issues
  • How to mock API calls using jest.mock and Mock Service Worker (MSW)
  • How to properly test loading, success, and error states in data-fetching components
  • What the act() warning means, why React throws it, and how to fix it every time

The Concept — Testing the Invisible Machinery

Analogy: The Car Engine Diagnostic

Imagine you are a car mechanic. A customer brings in a car and says "test it." The easy test is to check the exterior: does the paint look good, do the doors open, do the lights turn on? That is like testing whether a component renders text on screen.

But the real problems hide inside the engine. The fuel injection system fires at precise intervals. The transmission shifts gears asynchronously based on speed. The oxygen sensor reads air quality and adjusts the fuel mix after a delay. You cannot test these by looking at the car from the outside. You need specialized diagnostic tools that hook directly into the engine, simulate driving conditions, and measure what happens over time.

Testing custom hooks is like testing the fuel injection system in isolation — you pull it out of the car (the component) and run it on a test bench (renderHook). Testing async code is like testing the transmission — you simulate the driving conditions (mock API responses) and verify the gear shifts happen correctly over time (await state changes). The act() warning is the diagnostic tool telling you: "the engine was still running when you tried to read the gauge — wait for it to settle first."

You cannot claim a car works just because the paint looks good. And you cannot claim a React component works just because it renders a heading.


Testing Custom Hooks with renderHook

Custom hooks contain logic that lives outside any specific component. Testing them by mounting a dummy wrapper component every time is tedious. The renderHook utility from React Testing Library gives you a test bench to run hooks in isolation.

Code Example 1: Testing a useCounter Hook

// useCounter.js — a custom hook to test
import { useState, useCallback } from "react";

// A simple counter hook with increment, decrement, and reset
export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
// useCounter.test.js — testing the hook in isolation
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";

// renderHook runs the hook outside of any component
// It returns a "result" object whose .current property holds the hook return value

test("should start with the default initial value of 0", () => {
  const { result } = renderHook(() => useCounter());

  // result.current is the return value of useCounter()
  expect(result.current.count).toBe(0);
});

test("should accept a custom initial value", () => {
  const { result } = renderHook(() => useCounter(10));

  expect(result.current.count).toBe(10);
});

test("should increment the counter", () => {
  const { result } = renderHook(() => useCounter());

  // act() wraps any code that triggers a state update
  // Without act(), React warns that state updated outside of act()
  act(() => {
    result.current.increment();
  });

  // After act() completes, result.current reflects the updated state
  expect(result.current.count).toBe(1);
});

test("should decrement the counter", () => {
  const { result } = renderHook(() => useCounter(5));

  act(() => {
    result.current.decrement();
  });

  expect(result.current.count).toBe(4);
});

test("should reset to the initial value", () => {
  const { result } = renderHook(() => useCounter(3));

  // Increment a few times, then reset
  act(() => {
    result.current.increment();
    result.current.increment();
  });

  expect(result.current.count).toBe(5);

  act(() => {
    result.current.reset();
  });

  // After reset, count returns to the initial value (3), not to 0
  expect(result.current.count).toBe(3);
});

// Output (all tests pass):
// PASS useCounter.test.js
//   should start with the default initial value of 0
//   should accept a custom initial value
//   should increment the counter
//   should decrement the counter
//   should reset to the initial value

Key point: renderHook runs your hook in a lightweight test component behind the scenes. You never write that wrapper yourself. The result.current property always reflects the latest return value after any state updates wrapped in act().


Testing useEffect and Async Operations

Components that fetch data on mount use useEffect with async logic. Testing these requires you to mock the network layer and wait for state transitions: loading starts, data arrives (or an error occurs), loading ends.

Code Example 2: Testing a Data-Fetching Hook with jest.mock

// useFetchUser.js — a hook that fetches user data on mount
import { useState, useEffect } from "react";

export function useFetchUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false; // Prevent state updates if the component unmounts

    async function fetchUser() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error("Failed to fetch user");
        const data = await response.json();

        // Only update state if the effect has not been cleaned up
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      }
    }

    fetchUser();

    // Cleanup: if userId changes or component unmounts, cancel pending updates
    return () => {
      cancelled = true;
    };
  }, [userId]);

  return { user, loading, error };
}
// useFetchUser.test.js — testing async hook with mocked fetch
import { renderHook, waitFor } from "@testing-library/react";
import { useFetchUser } from "./useFetchUser";

// Mock the global fetch function before each test
// This prevents real network requests and gives us control over responses
beforeEach(() => {
  global.fetch = jest.fn();
});

afterEach(() => {
  jest.restoreAllMocks();
});

test("should return user data after successful fetch", async () => {
  // Set up the mock to return a successful response
  const mockUser = { id: 1, name: "Alice", email: "alice@dev.io" };
  global.fetch.mockResolvedValueOnce({
    ok: true,
    json: async () => mockUser,
  });

  const { result } = renderHook(() => useFetchUser(1));

  // Initially, loading is true and user is null
  expect(result.current.loading).toBe(true);
  expect(result.current.user).toBe(null);

  // waitFor retries the assertion until it passes or times out
  // It handles the async state updates automatically — no manual act() needed
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  // After loading completes, user data is available
  expect(result.current.user).toEqual(mockUser);
  expect(result.current.error).toBe(null);

  // Verify fetch was called with the correct URL
  expect(global.fetch).toHaveBeenCalledWith("/api/users/1");
});

test("should return error when fetch fails", async () => {
  // Set up the mock to return a failed response
  global.fetch.mockResolvedValueOnce({
    ok: false,
  });

  const { result } = renderHook(() => useFetchUser(1));

  // Wait for the error state to appear
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  // Error is set, user remains null
  expect(result.current.error).toBe("Failed to fetch user");
  expect(result.current.user).toBe(null);
});

test("should refetch when userId changes", async () => {
  const user1 = { id: 1, name: "Alice" };
  const user2 = { id: 2, name: "Bob" };

  global.fetch
    .mockResolvedValueOnce({ ok: true, json: async () => user1 })
    .mockResolvedValueOnce({ ok: true, json: async () => user2 });

  // renderHook accepts an initialProps option
  // The rerender function lets you pass new props to trigger the hook again
  const { result, rerender } = renderHook(
    ({ userId }) => useFetchUser(userId),
    { initialProps: { userId: 1 } }
  );

  await waitFor(() => expect(result.current.loading).toBe(false));
  expect(result.current.user).toEqual(user1);

  // Change the userId prop — this triggers the useEffect again
  rerender({ userId: 2 });

  await waitFor(() => expect(result.current.user).toEqual(user2));
  expect(global.fetch).toHaveBeenCalledTimes(2);
});

// Output (all tests pass):
// PASS useFetchUser.test.js
//   should return user data after successful fetch
//   should return error when fetch fails
//   should refetch when userId changes

Mocking API Calls with MSW (Mock Service Worker)

jest.mock works but has a drawback: it mocks at the module level, so your test is tightly coupled to how your code calls fetch. Mock Service Worker (MSW) intercepts requests at the network level, meaning your code runs exactly as it would in production — it calls fetch normally, and MSW intercepts the request before it leaves the browser (or Node process).

Code Example 3: MSW Setup for Component Tests

// mocks/handlers.js — define mock API handlers
import { http, HttpResponse } from "msw";

// Handlers intercept real network requests and return mock responses
// Your component code does not know it is talking to a mock
export const handlers = [
  // Intercept GET /api/users/:id
  http.get("/api/users/:id", ({ params }) => {
    const { id } = params;

    // Simulate different responses based on the request
    if (id === "999") {
      return HttpResponse.json(
        { message: "User not found" },
        { status: 404 }
      );
    }

    return HttpResponse.json({
      id: Number(id),
      name: "Alice",
      email: "alice@dev.io",
    });
  }),
];
// mocks/server.js — create the MSW server for Node (Jest runs in Node)
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
// setupTests.js — start MSW before tests, clean up after
import { server } from "./mocks/server";

// Start the server before all tests in this file
beforeAll(() => server.listen());

// Reset handlers between tests so one test does not affect another
afterEach(() => server.resetHandlers());

// Shut down the server after all tests complete
afterAll(() => server.close());
// UserProfile.test.jsx — test the component with MSW handling the network
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "./mocks/server";
import { UserProfile } from "./UserProfile";

test("displays user name after loading", async () => {
  // MSW handlers already return a successful response for /api/users/:id
  // No need to mock fetch — MSW intercepts it at the network level
  render(<UserProfile userId={1} />);

  // Loading state appears first
  expect(screen.getByText("Loading...")).toBeInTheDocument();

  // Wait for the user name to appear — MSW returns "Alice" by default
  await waitFor(() => {
    expect(screen.getByText("Alice")).toBeInTheDocument();
  });

  // Loading indicator should be gone
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

test("displays error message when user is not found", async () => {
  // Override the default handler for this ONE test
  // server.use temporarily replaces the matching handler
  server.use(
    http.get("/api/users/:id", () => {
      return HttpResponse.json(
        { message: "User not found" },
        { status: 404 }
      );
    })
  );

  render(<UserProfile userId={999} />);

  await waitFor(() => {
    expect(screen.getByText("Failed to fetch user")).toBeInTheDocument();
  });
});

test("displays error when network request fails entirely", async () => {
  // Simulate a network error — the request never completes
  server.use(
    http.get("/api/users/:id", () => {
      return HttpResponse.error();
    })
  );

  render(<UserProfile userId={1} />);

  await waitFor(() => {
    expect(screen.getByRole("alert")).toBeInTheDocument();
  });
});

// Output (all tests pass):
// PASS UserProfile.test.jsx
//   displays user name after loading
//   displays error message when user is not found
//   displays error when network request fails entirely

Why MSW over jest.mock: Your component calls fetch() exactly like in production. You do not mock module imports. You do not couple your tests to implementation details. If you refactor from fetch to axios, your MSW tests still work without changes.


Understanding act() — Why React Yells at You

The act() function is React's way of saying: "wrap everything that causes state updates so I can process them before you make assertions." When you see the warning An update to Component inside a test was not wrapped in act(...), it means a state update happened after your test already moved on to assertions.

Code Example 4: The act() Warning Explained and Fixed

// WHY THE WARNING HAPPENS
// ========================

// Imagine this component:
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((s) => s + 1); // State update happens AFTER the test moves on
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>{seconds} seconds</p>;
}

// BAD TEST — triggers act() warning
test("shows timer", () => {
  render(<Timer />);
  expect(screen.getByText("0 seconds")).toBeInTheDocument();
  // Test ends here, but the setInterval is still running
  // React tries to update state AFTER the test is done
  // Warning: An update to Timer inside a test was not wrapped in act(...)
});

// GOOD TEST — properly handles the async update
test("shows timer incrementing", async () => {
  // Use fake timers so we control time precisely
  jest.useFakeTimers();

  render(<Timer />);
  expect(screen.getByText("0 seconds")).toBeInTheDocument();

  // Advance time by 1 second inside act()
  // act() tells React: "I am about to trigger state updates — process them"
  act(() => {
    jest.advanceTimersByTime(1000);
  });

  // Now React has processed the state update — safe to assert
  expect(screen.getByText("1 seconds")).toBeInTheDocument();

  // Clean up fake timers
  jest.useRealTimers();
});

// ANOTHER COMMON CASE — async operations
// When using waitFor or findBy queries, act() is handled automatically
// React Testing Library wraps these utilities in act() for you

test("loads data without act warning", async () => {
  render(<UserProfile userId={1} />);

  // findByText internally uses waitFor, which wraps in act()
  // No manual act() needed — this is the recommended approach
  const userName = await screen.findByText("Alice");
  expect(userName).toBeInTheDocument();
});

// WHEN YOU NEED MANUAL act():
// 1. Direct state updates via renderHook: act(() => result.current.increment())
// 2. Fake timer advances: act(() => jest.advanceTimersByTime(1000))
// 3. Manual promise resolution: await act(async () => { await somePromise; })
//
// WHEN act() IS AUTOMATIC:
// 1. render() — already wrapped in act()
// 2. userEvent.click() — already wrapped in act()
// 3. waitFor() — already wrapped in act()
// 4. findBy queries — already wrapped in act()

// Output:
// PASS Timer.test.jsx
//   shows timer incrementing
//   loads data without act warning

Testing Hooks & Async Code visual 1


Testing Hooks & Async Code visual 2


Testing Hooks & Async Code visual 3


Common Mistakes

Mistake 1: Not waiting for async state updates

// BAD: Asserting immediately after render — the fetch has not completed yet
test("shows user", () => {
  render(<UserProfile userId={1} />);
  // This runs BEFORE the useEffect fetch resolves
  // The component is still in the loading state
  expect(screen.getByText("Alice")).toBeInTheDocument(); // FAILS
});

// GOOD: Use waitFor or findBy to wait for the async update
test("shows user after loading", async () => {
  render(<UserProfile userId={1} />);
  // findByText waits until the element appears (up to 1 second by default)
  const name = await screen.findByText("Alice");
  expect(name).toBeInTheDocument();
});

Mistake 2: Mocking too much or too little with jest.mock

// BAD: Mocking the entire hook — you are no longer testing real behavior
jest.mock("./useFetchUser", () => ({
  useFetchUser: () => ({
    user: { name: "Alice" },
    loading: false,
    error: null,
  }),
}));

test("shows user", () => {
  render(<UserProfile userId={1} />);
  // This passes, but you are not testing the component AT ALL
  // You are testing that your mock returns what you told it to return
  expect(screen.getByText("Alice")).toBeInTheDocument();
});

// GOOD: Mock at the network level (MSW) and let the real hook run
// The component calls fetch, MSW returns mock data, the hook processes it
// You are testing real component behavior with controlled network responses
test("shows user with MSW", async () => {
  render(<UserProfile userId={1} />);
  await screen.findByText("Alice");
});

Mistake 3: Forgetting act() when updating hook state in tests

// BAD: Calling hook methods without act() wrapper
test("increments counter", () => {
  const { result } = renderHook(() => useCounter());

  // This triggers a state update OUTSIDE of act()
  result.current.increment(); // Warning: An update was not wrapped in act(...)

  expect(result.current.count).toBe(1); // May or may not reflect the update
});

// GOOD: Wrap state-triggering calls in act()
test("increments counter", () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  // State is guaranteed to be updated after act() completes
  expect(result.current.count).toBe(1);
});

Interview Questions

Q: How do you test a custom hook without mounting a component?

Use renderHook from @testing-library/react. It runs the hook inside a hidden test component and returns a result object. result.current holds the hook's return value and updates after state changes. Wrap any calls that trigger state updates in act(). Use rerender to simulate prop changes. Example: const { result } = renderHook(() => useCounter()), then act(() => result.current.increment()), then expect(result.current.count).toBe(1).

Q: What is the act() warning and how do you fix it?

The act() warning means a state update happened outside of React's awareness during a test. React needs to know when state changes occur so it can flush updates before assertions run. The fix depends on the scenario: for direct state updates in renderHook, wrap them in act(() => { ... }). For async operations, use waitFor() or findBy queries from React Testing Library, which handle act() internally. For timers, use jest.useFakeTimers() and advance time inside act(). The core rule: anything that causes a state update must complete before you assert.

Q: What is the difference between jest.mock and MSW for mocking API calls?

jest.mock replaces module exports at the import level — you mock the fetch function itself or the module that calls it. This is fast and simple but couples your test to implementation details (if you switch from fetch to axios, tests break). MSW (Mock Service Worker) intercepts HTTP requests at the network level — your code calls fetch normally, and MSW returns mock responses before the request reaches the network. MSW tests are more realistic, survive refactors, and can be reused between unit tests, integration tests, and even Storybook. MSW is the recommended approach for testing data-fetching components.

Q: How do you test loading and error states in a component that fetches data?

Render the component and immediately assert the loading state (it should be synchronously visible before the fetch resolves). For the success state, use await screen.findByText("data") or await waitFor(() => expect(...)) to wait for the async update. For error states, use server.use() with MSW to override the handler for that specific test and return an error response, then wait for the error message to appear. Always test all three states: loading, success, and error. The key is that loading is synchronous (assert immediately), while success and error are asynchronous (use waitFor/findBy).

Q: When should you use renderHook versus testing the hook through a component?

Use renderHook when testing hook logic in isolation — when you want to verify that the hook's state transitions, return values, and side effects work correctly independent of any UI. Use component-level testing (render + screen queries) when testing how the hook integrates with a specific component — when the UI behavior matters. A good practice is to test complex hooks with renderHook for unit-level coverage, then test the component that uses the hook with render for integration-level coverage. If the hook is simple and only used in one component, testing through the component is often sufficient.


Quick Reference — Cheat Sheet

TESTING HOOKS & ASYNC CODE
=====================================

renderHook basics:
  import { renderHook, act } from "@testing-library/react";

  const { result }       = renderHook(() => useMyHook());
  result.current         // current return value of the hook
  act(() => { ... })     // wrap state-triggering calls
  rerender(newProps)     // re-run the hook with new props

Testing async hooks:
  import { renderHook, waitFor } from "@testing-library/react";

  const { result } = renderHook(() => useFetchData(url));
  expect(result.current.loading).toBe(true);          // sync assert
  await waitFor(() => {
    expect(result.current.loading).toBe(false);        // async assert
  });

Mocking fetch (jest.mock):
  beforeEach(() => { global.fetch = jest.fn(); });
  global.fetch.mockResolvedValueOnce({ ok: true, json: async () => data });
  global.fetch.mockRejectedValueOnce(new Error("Network error"));

Mocking with MSW:
  // handlers.js
  import { http, HttpResponse } from "msw";
  export const handlers = [
    http.get("/api/data", () => HttpResponse.json({ key: "value" })),
  ];

  // server.js
  import { setupServer } from "msw/node";
  export const server = setupServer(...handlers);

  // setupTests.js
  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());

  // Override for one test:
  server.use(http.get("/api/data", () => HttpResponse.error()));

Testing loading/error states:
  render(<Component />);
  expect(screen.getByText("Loading...")).toBeInTheDocument();  // immediate
  await screen.findByText("Data loaded");                      // wait for success
  // OR for error test:
  server.use(http.get("/api/data", () => HttpResponse.json({}, { status: 500 })));
  render(<Component />);
  await screen.findByText("Something went wrong");

act() rules:
  +-------------------------------+---------------------------+
  | Scenario                      | act() needed?             |
  +-------------------------------+---------------------------+
  | render(<Comp />)              | No (RTL wraps it)         |
  | userEvent.click(el)           | No (RTL wraps it)         |
  | waitFor(() => ...)            | No (RTL wraps it)         |
  | screen.findByText(...)        | No (RTL wraps it)         |
  | result.current.increment()    | YES — wrap in act()       |
  | jest.advanceTimersByTime()    | YES — wrap in act()       |
  | Manual promise resolution     | YES — await act(async..)  |
  +-------------------------------+---------------------------+

jest.mock vs MSW:
  +-------------------+----------------------------+---------------------------+
  | Aspect            | jest.mock                  | MSW                       |
  +-------------------+----------------------------+---------------------------+
  | Intercepts at     | Module/import level        | Network level             |
  | Coupling          | Tied to implementation     | Tied to API contract      |
  | Refactor-safe     | No (fetch -> axios breaks) | Yes (request is the same) |
  | Reusable          | Per test file              | Across test files + tools |
  | Setup effort      | Low                        | Medium (handlers + server)|
  | Realism           | Low (skips real code)      | High (real code runs)     |
  +-------------------+----------------------------+---------------------------+

Previous: Lesson 10.1 — Testing React Components -> Next: Lesson 10.3 — Common Interview Coding Challenges ->


This is Lesson 10.2 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page