Skip to content

Commit 3c93397

Browse files
authored
feat(useErrorHandler): add new hook for handling errors (#59)
1 parent ded843a commit 3c93397

File tree

5 files changed

+236
-31
lines changed

5 files changed

+236
-31
lines changed

README.md

Lines changed: 121 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,7 @@ then be gracefully handled.
3838
- [Error Recovery](#error-recovery)
3939
- [API](#api)
4040
- [`ErrorBoundary` props](#errorboundary-props)
41-
- [`children`](#children)
42-
- [`FallbackComponent`](#fallbackcomponent)
43-
- [`fallbackRender`](#fallbackrender)
44-
- [`fallback`](#fallback)
45-
- [`onError`](#onerror)
46-
- [`onReset`](#onreset)
47-
- [`resetKeys`](#resetkeys)
48-
- [`onResetKeysChange`](#onresetkeyschange)
41+
- [`useErrorHandler(error?: Error)`](#useerrorhandlererror-error)
4942
- [Issues](#issues)
5043
- [🐛 Bugs](#-bugs)
5144
- [💡 Feature Requests](#-feature-requests)
@@ -190,13 +183,13 @@ specific scenario.
190183

191184
### `ErrorBoundary` props
192185

193-
### `children`
186+
#### `children`
194187

195188
This is what you want rendered when everything's working fine. If there's an
196189
error that React can handle within the children of the `ErrorBoundary`, the
197190
`ErrorBoundary` will catch that and allow you to handle it gracefully.
198191

199-
### `FallbackComponent`
192+
#### `FallbackComponent`
200193

201194
This is a component you want rendered in the event of an error. As props it will
202195
be passed the `error`, `componentStack`, and `resetErrorBoundary` (which will
@@ -205,7 +198,7 @@ when used in combination with the `onReset` prop).
205198

206199
This is required if no `fallback` or `fallbackRender` prop is provided.
207200

208-
### `fallbackRender`
201+
#### `fallbackRender`
209202

210203
This is a render-prop based API that allows you to inline your error fallback UI
211204
into the component that's using the `ErrorBoundary`. This is useful if you need
@@ -247,7 +240,7 @@ problem.
247240

248241
This is required if no `FallbackComponent` or `fallback` prop is provided.
249242

250-
### `fallback`
243+
#### `fallback`
251244

252245
In the spirit of consistency with the `React.Suspense` component, we also
253246
support a simple `fallback` prop which you can use for a generic fallback. This
@@ -262,12 +255,12 @@ const ui = (
262255
)
263256
```
264257

265-
### `onError`
258+
#### `onError`
266259

267260
This will be called when there's been an error that the `ErrorBoundary` has
268261
handled. It will be called with two arguments: `error`, `componentStack`.
269262

270-
### `onReset`
263+
#### `onReset`
271264

272265
This will be called immediately before the `ErrorBoundary` resets it's internal
273266
state (which will result in rendering the `children` again). You should use this
@@ -279,7 +272,7 @@ error happening again.
279272
**Important**: `onReset` will _not_ be called when reset happens from a change
280273
in `resetKeys`. Use `onResetKeysChange` for that.
281274

282-
### `resetKeys`
275+
#### `resetKeys`
283276

284277
Sometimes an error happens as a result of local state to the component that's
285278
rendering the error. If this is the case, then you can pass `resetKeys` which is
@@ -289,11 +282,123 @@ then it will reset automatically (triggering a re-render of the `children`).
289282

290283
See the recovery examples above.
291284

292-
### `onResetKeysChange`
285+
#### `onResetKeysChange`
293286

294287
This is called when the `resetKeys` are changed (triggering a reset of the
295288
`ErrorBoundary`). It's called with the `prevResetKeys` and the `resetKeys`.
296289

290+
### `useErrorHandler(error?: Error)`
291+
292+
React's error boundaries feature is limited in that the boundaries can only
293+
handle errors thrown during React's lifecycles. To quote
294+
[the React docs on Error Boundaries](https://reactjs.org/docs/error-boundaries.html):
295+
296+
> Error boundaries do not catch errors for:
297+
>
298+
> - Event handlers
299+
> ([learn more](https://reactjs.org/docs/error-boundaries.html#how-about-event-handlers))
300+
> - Asynchronous code (e.g. setTimeout or requestAnimationFrame callbacks)
301+
> - Server side rendering
302+
> - Errors thrown in the error boundary itself (rather than its children)
303+
304+
This means you have to handle those errors yourself, but you probably would like
305+
to reuse the error boundaries you worked hard on creating for those kinds of
306+
errors as well. This is what `useErrorHandler` is for.
307+
308+
There are two ways to use `useErrorHandler`:
309+
310+
1. `const handleError = useErrorHandler()`: call `handleError(theError)`
311+
2. `useErrorHandler(error)`: useful if you are managing the error state yourself
312+
or get it from another hook.
313+
314+
Here's an example:
315+
316+
```javascript
317+
function Greeting() {
318+
const [greeting, setGreeting] = React.useState(null)
319+
const handleError = useHandleError()
320+
321+
function handleSubmit(event) {
322+
event.preventDefault()
323+
const name = event.target.elements.name.value
324+
fetchGreeting(name).then(
325+
newGreeting => setGreeting(newGreeting),
326+
handleError,
327+
)
328+
}
329+
330+
return greeting ? (
331+
<div>{greeting}</div>
332+
) : (
333+
<form onSubmit={handleSubmit}>
334+
<label>Name</label>
335+
<input id="name" />
336+
<button type="submit" onClick={handleClick}>
337+
get a greeting
338+
</button>
339+
</form>
340+
)
341+
}
342+
```
343+
344+
> Note, in case it's not clear what's happening here, you could also write
345+
> `handleClick` like this:
346+
347+
```javascript
348+
function handleSubmit(event) {
349+
event.preventDefault()
350+
const name = event.target.elements.name.value
351+
fetchGreeting(name).then(
352+
newGreeting => setGreeting(newGreeting),
353+
error => handleError(error),
354+
)
355+
}
356+
```
357+
358+
Alternatively, let's say you're using a hook that gives you the error:
359+
360+
```javascript
361+
function Greeting() {
362+
const [name, setName] = React.useState('')
363+
const {greeting, error} = useGreeting(name)
364+
useHandleError(error)
365+
366+
function handleSubmit(event) {
367+
event.preventDefault()
368+
const name = event.target.elements.name.value
369+
setName(name)
370+
}
371+
372+
return greeting ? (
373+
<div>{greeting}</div>
374+
) : (
375+
<form onSubmit={handleSubmit}>
376+
<label>Name</label>
377+
<input id="name" />
378+
<button type="submit" onClick={handleClick}>
379+
get a greeting
380+
</button>
381+
</form>
382+
)
383+
}
384+
```
385+
386+
In this case, if the `error` is ever set to a truthy value, then it will be
387+
propagated to the nearest error boundary.
388+
389+
In either case, you could handle those errors like this:
390+
391+
```javascript
392+
const ui = (
393+
<ErrorBoundary FallbackComponent={ErrorFallback}>
394+
<Greeting />
395+
</ErrorBoundary>
396+
)
397+
```
398+
399+
And now that'll handle your runtime errors as well as the async errors in the
400+
`fetchGreeting` or `useGreeting` code.
401+
297402
## Issues
298403

299404
_Looking to contribute? Look for the [Good First Issue][good-first-issue]

index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ export function withErrorBoundary<P>(
4141
ComponentToDecorate: React.ComponentType<P>,
4242
errorBoundaryProps: ErrorBoundaryProps,
4343
): React.ComponentType<P>
44+
45+
export function useErrorHandler<P = Error>(
46+
error?: P,
47+
): React.Dispatch<React.SetStateAction<P>>

src/__tests__/hook.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React from 'react'
2+
import userEvent from '@testing-library/user-event'
3+
import {render, screen} from '@testing-library/react'
4+
import {ErrorBoundary, useErrorHandler} from '..'
5+
6+
function ErrorFallback({error, componentStack, resetErrorBoundary}) {
7+
return (
8+
<div role="alert">
9+
<p>Something went wrong:</p>
10+
<pre>{error.message}</pre>
11+
<pre>{componentStack}</pre>
12+
<button onClick={resetErrorBoundary}>Try again</button>
13+
</div>
14+
)
15+
}
16+
17+
const firstLine = str => str.split('\n')[0]
18+
19+
test('handleError forwards along async errors', async () => {
20+
function AsyncBomb() {
21+
const [explode, setExplode] = React.useState(false)
22+
const handleError = useErrorHandler()
23+
React.useEffect(() => {
24+
if (explode) {
25+
setTimeout(() => {
26+
handleError(new Error('💥 CABOOM 💥'))
27+
})
28+
}
29+
})
30+
return <button onClick={() => setExplode(true)}>bomb</button>
31+
}
32+
render(
33+
<ErrorBoundary FallbackComponent={ErrorFallback}>
34+
<AsyncBomb />
35+
</ErrorBoundary>,
36+
)
37+
38+
userEvent.click(screen.getByRole('button', {name: /bomb/i}))
39+
40+
await screen.findByRole('alert')
41+
42+
const [[actualError], [componentStack]] = console.error.mock.calls
43+
const firstLineOfError = firstLine(actualError)
44+
expect(firstLineOfError).toMatchInlineSnapshot(
45+
`"Error: Uncaught [Error: 💥 CABOOM 💥]"`,
46+
)
47+
expect(componentStack).toMatchInlineSnapshot(`
48+
"The above error occurred in one of your React components:
49+
in Unknown
50+
in ErrorBoundary
51+
52+
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary."
53+
`)
54+
expect(console.error).toHaveBeenCalledTimes(2)
55+
console.error.mockClear()
56+
57+
// can recover
58+
userEvent.click(screen.getByRole('button', {name: /try again/i}))
59+
expect(console.error).not.toHaveBeenCalled()
60+
})
61+
62+
test('can pass an error to useErrorHandler', async () => {
63+
function AsyncBomb() {
64+
const [error, setError] = React.useState(null)
65+
const [explode, setExplode] = React.useState(false)
66+
useErrorHandler(error)
67+
React.useEffect(() => {
68+
if (explode) {
69+
setTimeout(() => {
70+
setError(new Error('💥 CABOOM 💥'))
71+
})
72+
}
73+
})
74+
return <button onClick={() => setExplode(true)}>bomb</button>
75+
}
76+
render(
77+
<ErrorBoundary FallbackComponent={ErrorFallback}>
78+
<AsyncBomb />
79+
</ErrorBoundary>,
80+
)
81+
82+
userEvent.click(screen.getByRole('button', {name: /bomb/i}))
83+
84+
await screen.findByRole('alert')
85+
const [[actualError], [componentStack]] = console.error.mock.calls
86+
const firstLineOfError = firstLine(actualError)
87+
expect(firstLineOfError).toMatchInlineSnapshot(
88+
`"Error: Uncaught [Error: 💥 CABOOM 💥]"`,
89+
)
90+
expect(componentStack).toMatchInlineSnapshot(`
91+
"The above error occurred in one of your React components:
92+
in Unknown
93+
in ErrorBoundary
94+
95+
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary."
96+
`)
97+
expect(console.error).toHaveBeenCalledTimes(2)
98+
console.error.mockClear()
99+
100+
// can recover
101+
userEvent.click(screen.getByRole('button', {name: /try again/i}))
102+
expect(console.error).not.toHaveBeenCalled()
103+
})

src/__tests__/index.js

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,6 @@ function Bomb() {
2020

2121
const firstLine = str => str.split('\n')[0]
2222

23-
beforeEach(() => {
24-
jest.spyOn(console, 'error').mockImplementation(() => {})
25-
})
26-
27-
afterEach(() => {
28-
try {
29-
expect(console.error).not.toHaveBeenCalled()
30-
} catch (e) {
31-
throw new Error(
32-
`console.error was called unexpectedly (make sure to assert all calls and console.error.mockClear() at the end of the test)`,
33-
)
34-
}
35-
})
36-
3723
test('standard use-case', async () => {
3824
function App() {
3925
const [username, setUsername] = React.useState('')

src/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,11 @@ function withErrorBoundary(Component, errorBoundaryProps) {
6868
return Wrapped
6969
}
7070

71-
export {ErrorBoundary, withErrorBoundary}
71+
function useErrorHandler(givenError) {
72+
const [error, setError] = React.useState(null)
73+
if (givenError) throw givenError
74+
if (error) throw error
75+
return setError
76+
}
77+
78+
export {ErrorBoundary, withErrorBoundary, useErrorHandler}

0 commit comments

Comments
 (0)