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!"))})