Advanced Hooks
Context
Often, a hook is going to need a value out of context. The useContext
hook is really good for
this, but it will often require a Provider
to be wrapped around the component using the hook. We
can use the wrapper
option for renderHook
to do just that.
Let's change the useCounter
example from the Basic Hooks section to get a
step
value from context and build a CounterStepProvider
that allows us to set the value:
import React, { useState, useContext, useCallback } from 'react'const CounterStepContext = React.createContext(1)export const CounterStepProvider = ({ step, children }) => (<CounterStepContext.Provider value={step}>{children}</CounterStepContext.Provider>)export function useCounter(initialValue = 0) {const [count, setCount] = useState(initialValue)const step = useContext(CounterStepContext)const increment = useCallback(() => setCount((x) => x + step), [step])const reset = useCallback(() => setCount(initialValue), [initialValue])return { count, increment, reset }}
In our test, we simply use CounterStepProvider
as the wrapper
when rendering the hook:
import { renderHook, act } from '@testing-library/react-hooks'import { CounterStepProvider, useCounter } from './counter'test('should use custom step when incrementing', () => {const wrapper = ({ children }) => <CounterStepProvider step={2}>{children}</CounterStepProvider>const { result } = renderHook(() => useCounter(), { wrapper })act(() => {result.current.increment()})expect(result.current.count).toBe(2)})
The wrapper
option will accept any React component, but it must render children
in order for
the test component to render and the hook to execute.
Providing Props
Sometimes we need to test a hook with different context values. By using the initialProps
option
and the new props of rerender
method, we can easily do this:
import { renderHook, act } from '@testing-library/react-hooks'import { CounterStepProvider, useCounter } from './counter'test('should use custom step when incrementing', () => {const wrapper = ({ children, step }) => (<CounterStepProvider step={step}>{children}</CounterStepProvider>)const { result, rerender } = renderHook(() => useCounter(), {wrapper,initialProps: {step: 2}})act(() => {result.current.increment()})expect(result.current.count).toBe(2)/*** Change the step value*/rerender({ step: 8 })act(() => {result.current.increment()})expect(result.current.count).toBe(10)})
Note the initialProps
and the new props of rerender
are also accessed by the callback function
of the renderHook
which the wrapper is provided to.
ESLint Warning
It can be very tempting to try to inline the wrapper
variable into the renderHook
line, and
there is nothing technically wrong with doing that, but if you are using
eslint
and
eslint-plugin-react
, you will see a linting
error that says:
Component definition is missing display name
This is caused by the react/display-name
rule and although it's unlikely to cause you any issues,
it's best to take steps to remove it. If you feel strongly about not having a separate wrapper
variable, you can disable the error for the test file by adding a special comment to the top of the
file:
/* eslint-disable react/display-name */import { renderHook, act } from '@testing-library/react-hooks'import { CounterStepProvider, useCounter } from './counter'test('should use custom step when incrementing', () => {const { result } = renderHook(() => useCounter(), {wrapper: ({ children }) => <CounterStepProvider step={2}>{children}</CounterStepProvider>})act(() => {result.current.increment()})expect(result.current.count).toBe(2)})
Similar techniques can be used to disable the error for just the specific line, or for the whole project, but please take the time to understand the impact that disabling linting rules will have on you, your team, and your project.
Async
Sometimes, a hook can trigger asynchronous updates that will not be immediately reflected in the
result.current
value. Luckily, renderHook
returns some utilities that allow the test to wait for
the hook to update using async/await
(or just promise callbacks if you prefer). The most basic
async utility is called waitForNextUpdate
.
Let's further extend useCounter
to have an incrementAsync
callback that will update the count
after 100ms
:
import React, { useState, useContext, useCallback } from 'react'export function useCounter(initialValue = 0) {const [count, setCount] = useState(initialValue)const step = useContext(CounterStepContext)const increment = useCallback(() => setCount((x) => x + step), [step])const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])const reset = useCallback(() => setCount(initialValue), [initialValue])return { count, increment, incrementAsync, reset }}
To test incrementAsync
we need to await waitForNextUpdate()
before making our assertions:
import { renderHook } from '@testing-library/react-hooks'import { useCounter } from './counter'test('should increment counter after delay', async () => {const { result, waitForNextUpdate } = renderHook(() => useCounter())result.current.incrementAsync()await waitForNextUpdate()expect(result.current.count).toBe(1)})
Wrapping incrementAsync
in act()
is not necessary since the state updates happen asynchronously
during await waitForNextUpdate()
. The async utilities automatically wrap the waiting code in the
asynchronous act()
wrapper.
For more details on the other async utilities, please refer to the API Reference.
Suspense
All the async utilities will also wait for hooks that suspend
using React's Suspense
functionality to
complete rendering.
Errors
If you need to test that a hook throws the errors you expect it to, you can use result.error
to
access an error that may have been thrown in the previous render. For example, we could make the
useCounter
hook threw an error if the count reached a specific value:
import React, { useState, useContext, useCallback } from 'react'export function useCounter(initialValue = 0) {const [count, setCount] = useState(initialValue)const step = useContext(CounterStepContext)const increment = useCallback(() => setCount((x) => x + step), [step])const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])const reset = useCallback(() => setCount(initialValue), [initialValue])if (count > 9000) {throw Error("It's over 9000!")}return { count, increment, incrementAsync, reset }}
import { renderHook, act } from '@testing-library/react-hooks'import { useCounter } from './counter'it('should throw when over 9000', () => {const { result } = renderHook(() => useCounter(9000))act(() => {result.current.increment()})expect(result.error).toEqual(Error("It's over 9000!"))})