Skip to content

Commit acbe986

Browse files
committed
Expanded advanced hooks docs to include context, async, suspence and errors
1 parent 3930a0f commit acbe986

File tree

4 files changed

+268
-90
lines changed

4 files changed

+268
-90
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ import useCounter from './useCounter'
8787
test('should increment counter', () => {
8888
const { result } = renderHook(() => useCounter())
8989

90-
act(() => result.current.increment())
90+
act(() => {
91+
result.current.increment()
92+
})
9193

9294
expect(result.current.count).toBe(1)
9395
})

docs/usage/advanced-hooks.md

Lines changed: 119 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -6,147 +6,178 @@ route: '/usage/advanced-hooks'
66

77
# Advanced Hooks
88

9-
## Providing Props
9+
## Context
1010

11-
Sometimes a hook relies on the props passed to it in order to do it's thing. For example the `useCounter` hook we built in the [Basic Hooks](/usage/basic-hooks) section could easily accept the initial value of the counter:
11+
Often, a hook is going to need a value out of context. The `useContext` hook is really good for this, but it will ofter required a `Provider` to be wrapped around the component using the hook. We can use the `wrapper` option for `renderHook` to do just that.
12+
13+
Let's change the `useCounter` example from the [Basic Hooks section](/usage/basic-hooks) to get a `step` value from context and build a `CounterStepProvider` that allows us to set the value:
1214

1315
```js
14-
import { useState, useCallback } from 'react'
16+
import React, { useState, useContext, useCallback } from 'react'
17+
18+
const CounterStepContext = React.createContext(1)
1519

16-
export default function useCounter(initialValue = 0) {
20+
export const CounterStepProvider = ({ step, children }) => (
21+
<CounterStepContext.Provider value={step}>{children}</CounterStepContext.Provider>
22+
)
23+
24+
export function useCounter(initialValue = 0) {
1725
const [count, setCount] = useState(initialValue)
18-
const increment = useCallback(() => setCount((x) => x + 1), [])
19-
return { count, increment }
26+
const step = useContext(CounterStepContext)
27+
const increment = useCallback(() => setCount((x) => x + step), [step])
28+
const reset = useCallback(() => setCount(initialValue), [initialValue])
29+
return { count, increment, reset }
2030
}
2131
```
2232

23-
Overriding the `initialValue` prop in out test is as easy as calling the hook with the value we want to use:
33+
In our test, we simply use `CounterStepProvider` as the `wrapper` when rendering the hook:
2434

2535
```js
26-
import { renderHook, act } from 'react-hooks-testing-library'
27-
import useCounter from './useCounter'
36+
import { renderHook } from 'react-hooks-testing-library'
37+
import { CounterStepProvider, useCounter } from './counter'
2838

29-
test('should increment counter from custom initial value', () => {
30-
const { result } = renderHook(() => useCounter(9000))
39+
test('should use custom step when incrementing', () => {
40+
const wrapper = ({ children }) => <CounterStepProvider step={2}>{children}</CounterStepProvider>
41+
const { result } = renderHook(() => useCounter(), { wrapper })
3142

3243
act(() => {
3344
result.current.increment()
3445
})
3546

36-
expect(result.current.count).toBe(9001)
47+
expect(result.current.count).toBe(2)
3748
})
3849
```
3950

40-
### Changing Props
51+
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.
4152

42-
Many of the hook primitives use an array of dependent values to determine when to perform specific actions, such as recalculating an expensive value or running an effect. If we update our `useCounter` hook to have a `reset` function that resets the value to the `initialValue` it might look something like this:
53+
### ESLint Warning
54+
55+
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`](https://eslint.org/) and [`eslint-plugin-react`](https://github.com/yannickcr/eslint-plugin-react), you will see a linting error that says:
56+
57+
> Component definition is missing display name
58+
59+
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 seperate `wrapper` variable, you can disable the error for the test file but adding a special comment to the top of the file:
4360

4461
```js
45-
import { useState, useCallback } from 'react'
62+
/* eslint-disable react/display-name */
63+
64+
import { renderHook } from 'react-hooks-testing-library'
65+
import { CounterStepProvider, useCounter } from './counter'
4666

47-
export default function useCounter(initialValue = 0) {
67+
test('should use custom step when incrementing', () => {
68+
const { result } = renderHook(() => useCounter(), {
69+
wrapper: ({ children }) => <CounterStepProvider step={2}>{children}</CounterStepProvider>
70+
})
71+
72+
act(() => {
73+
result.current.increment()
74+
})
75+
76+
expect(result.current.count).toBe(2)
77+
})
78+
```
79+
80+
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.
81+
82+
## Async
83+
84+
Sometimes, a hook can trigger asynchronous updates that will not be immediately reflected in the `result.current` value. Luckily, `renderHook` returns a utility that allows the test to wait for the hook to update using `async/await` (or just promise callbacks if you prefer) called `waitForNextUpdate`.
85+
86+
Let's further extend `useCounter` to have an `incrementAsync` callback that will update the `count` after `100ms`:
87+
88+
```js
89+
import React, { useState, useContext, useCallback } from 'react'
90+
91+
export function useCounter(initialValue = 0) {
4892
const [count, setCount] = useState(initialValue)
49-
const increment = useCallback(() => setCount((x) => x + 1), [])
93+
const step = useContext(CounterStepContext)
94+
const increment = useCallback(() => setCount((x) => x + step), [step])
95+
const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])
5096
const reset = useCallback(() => setCount(initialValue), [initialValue])
51-
return { count, increment, reset }
97+
return { count, increment, incrementAsync, reset }
5298
}
5399
```
54100

55-
Now, the only time the `reset` function will be updated is if `initialValue` changes. The most basic way to handle changing the input props of our hook in a test is to simply update the value in a variable and rerender the hook:
101+
To test `incrementAsync` we need to `await waitForNextUpdate()` before the make our assertions:
56102

57103
```js
58104
import { renderHook, act } from 'react-hooks-testing-library'
59-
import useCounter from './useCounter'
105+
import { useCounter } from './counter'
60106

61-
test('should reset counter to updated initial value', () => {
62-
let initialValue = 0
63-
const { result, rerender } = renderHook(() => useCounter(initialValue))
107+
test('should increment counter after delay', async () => {
108+
const { result, waitForNextUpdate } = renderHook(() => useCounter())
64109

65-
initialValue = 10
66-
rerender()
110+
result.current.incrementAsync()
67111

68-
act(() => {
69-
result.current.reset()
70-
})
112+
await waitForNextUpdate()
71113

72-
expect(result.current.count).toBe(10)
114+
expect(result.current.count).toBe(1)
73115
})
74116
```
75117

76-
This is fine, but if there are lots of props, it can become a bit difficult to have variables to keep track of them all. Another option is to use the `initialProps` option and `newProps` of `rerender`:
118+
### Suspense
119+
120+
`waitForNextUpdate` will also wait for hooks that suspends using [React's `Suspense`](https://reactjs.org/docs/code-splitting.html#suspense) functionality finish rendering.
121+
122+
### `act` Warning
123+
124+
When testing async hooks, you will likely see a warning from React that tells you to wrap the update in `act(() => {...})`, but you can't because the update is internal to the hook code, not the test code. This is a [known issue](https://github.com/mpeyper/react-hooks-testing-library/issues/14) and should have a fix when React `v16.9.0` is released, but until then, you can either just ignore the warning, or suppress the output:
77125

78126
```js
79-
import { renderHook, act } from 'react-hooks-testing-library'
80-
import useCounter from './useCounter'
127+
import { renderHook } from 'react-hooks-testing-library'
128+
import { useCounter } from './counter'
81129

82-
test('should reset counter to updated initial value', () => {
83-
const { result, rerender } = renderHook(({ initialValue }) => useCounter(initialValue), {
84-
initialProps: { initialValue: 0 }
85-
})
130+
it('should increment counter after delay', async () => {
131+
const originalError = console.error
132+
console.error = jest.fn()
86133

87-
rerender({ initialValue: 10 })
134+
try {
135+
const { result, waitForNextUpdate } = renderHook(() => useCounter())
88136

89-
act(() => {
90-
result.current.reset()
91-
})
137+
result.current.incrementAsync()
138+
139+
await waitForNextUpdate()
92140

93-
expect(result.current.count).toBe(10)
141+
expect(result.current.count).toBe(1)
142+
} finally {
143+
console.error = originalError
144+
}
94145
})
95146
```
96147

97-
Another case where this is useful is when you want limit the scope of the variables being closed over to just be inside the hook callback. The following (contrived) example fails because the `id` value changes for both the setup and cleanup of the `useEffect` call:
148+
## Errors
149+
150+
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:
98151

99152
```js
100-
import { useEffect } from 'react'
101-
import { renderHook } from "react-hooks-testing-library"
102-
import sideEffect from './sideEffect
103-
104-
test("should clean up side effect", () => {
105-
let id = "first"
106-
const { rerender } = renderHook(() => {
107-
useEffect(() => {
108-
sideEffect.start(id)
109-
return () => {
110-
sideEffect.stop(id) // this id will get the new value when the effect is cleaned up
111-
}
112-
}, [id])
113-
})
153+
import React, { useState, useContext, useCallback } from 'react'
154+
155+
export function useCounter(initialValue = 0) {
156+
const [count, setCount] = useState(initialValue)
157+
const step = useContext(CounterStepContext)
158+
const increment = useCallback(() => setCount((x) => x + step), [step])
159+
const incrementAsync = useCallback(() => setTimeout(increment, 100), [increment])
160+
const reset = useCallback(() => setCount(initialValue), [initialValue])
114161

115-
id = "second"
116-
rerender()
162+
if (count > 9000) {
163+
throw Error("It's over 9000!")
164+
}
117165

118-
expect(sideEffect.get("first")).toBe(false)
119-
expect(sideEffect.get("second")).toBe(true)
120-
})
166+
return { count, increment, incrementAsync, reset }
167+
}
121168
```
122169

123-
By using the `initialProps` and `newProps` the captured `id` value from the first render is used to clean up the effect, allowing the test to pass as expected:
124-
125170
```js
126-
import { useEffect } from 'react'
127-
import { renderHook } from "react-hooks-testing-library"
128-
import sideEffect from './sideEffect
129-
130-
test("should clean up side effect", () => {
131-
const { rerender } = renderHook(
132-
({ id }) => {
133-
useEffect(() => {
134-
sideEffect.start(id)
135-
return () => {
136-
sideEffect.stop(id) // this id will get the new value when the effect is cleaned up
137-
}
138-
}, [id])
139-
},
140-
{
141-
initialProps: { id: "first" }
142-
}
143-
)
144-
145-
rerender({ id: "second" })
146-
147-
expect(thing.get("first")).toBe(false)
148-
expect(thing.get("second")).toBe(true)
171+
import { renderHook, act } from 'react-hooks-testing-library'
172+
import { useCounter } from './counter'
173+
174+
it('should throw when over 9000', () => {
175+
const { result } = renderHook(() => useCounter(9000))
176+
177+
act(() => {
178+
result.current.increment()
179+
}
180+
181+
expect(result.error).toEqual(Error("It's over 9000!"))
149182
})
150183
```
151-
152-
This is a fairly obscure case, so pick the method that fits best for you and your test.

0 commit comments

Comments
 (0)