useCart is a custom hook in the order checkout app. A custom hook is a function named with use that calls other hooks: useState for items, useMemo for the total. useCart holds the cart: items, addItem, removeItem, applyCoupon, and a computed total with the discount. It is called by the cart panel and checkout page - reusable logic, not tied to a single screen.
Hence the question: test the hook directly or through a component. If the hook is thin glue over a component's state, test it through the component, through the user's eyes. If it holds logic that several components share, it earns its own test in isolation - describing its contract independent of the interface.
The naive move is to treat the hook as an ordinary function and call it: const cart = useCart(); cart.addItem(...). It breaks at once: React throws Invalid hook call, since hooks may run only inside a component render - that is the rules of hooks. Outside a render there is no fiber to hold the state.
renderHook from @testing-library/react solves this. It mounts a tiny throwaway component whose job is to call your hook and store its return value. Back you get result, rerender, and unmount. The hook now runs inside a real React render.
The value lives in result.current. result is a reference to the most recently committed return value, think of it as a ref. Every render replaces the contents. So you read result.current at the moment you assert, not ahead of time into a variable - a captured copy stays a snapshot of an old render and goes stale.
State updates are wrapped in act. act tells React to apply the change and commit it before you assert; otherwise you read a value from before the update, and Jest warns not wrapped in act(...). Calling result.current.addItem inside act(() => {...}) applies the change and refreshes result.current. In component tests userEvent and findBy do this for you - in a bare hook test you wrap it by hand.
A hook that reacts to its arguments is tested through rerender. useDebouncedValue(value, 300) returns the value delayed - it damps rapid typing in a promo code field. renderHook takes initialProps, then rerender({ value: next }) simulates a prop change, you advance the fake timers, and assert result.current updated - the reaction to changing input, not just first mount.
A hook that reads context needs its providers above it. A server cart fetches data through QueryClientProvider, a shared one through its own CartProvider. The wrapper option takes a component that wraps children in the needed provider, and renderHook mounts the hook under it, otherwise it fails, finding no context.
An async hook that kicks off a request and settles later is tested through waitFor. await waitFor(() => expect(result.current.isSuccess).toBe(true)) retries the assertion until it passes or times out, and wraps updates in act itself. Manual setTimeout delays are not needed here.
The price of isolation is coupling to the hook's shape: such a test knows the signature more tightly than a component test and is justified only when the logic is genuinely reusable; test trivial glue through the component. The failure modes: asserting a stale result.current captured before act; an ignored act warning hiding an uncommitted update; and testing an implementation detail instead of the hook's contract. Test the contract: what values the hook exposes and what its actions do.
import { renderHook, act } from '@testing-library/react'
import { useCart } from './useCart'
test('a coupon lowers the total by the discount', () => {
const { result } = renderHook(() => useCart())
act(() => {
result.current.addItem({ id: 'sku-1', price: 1_000 })
result.current.applyCoupon('WELCOME10')
})
// read result.current here, not a copy captured before act
expect(result.current.total).toBe(900)
})import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { useServerCart } from './useServerCart'
test('loads the cart from the server', async () => {
const client = new QueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
const { result } = renderHook(() => useServerCart('user-1'), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.items).toHaveLength(2)
})