Skip to content

Commit

Permalink
feat(useErrorHandler): add new hook for handling errors (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
kentcdodds authored Jul 20, 2020
1 parent ded843a commit 3c93397
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 31 deletions.
137 changes: 121 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,7 @@ then be gracefully handled.
- [Error Recovery](#error-recovery)
- [API](#api)
- [`ErrorBoundary` props](#errorboundary-props)
- [`children`](#children)
- [`FallbackComponent`](#fallbackcomponent)
- [`fallbackRender`](#fallbackrender)
- [`fallback`](#fallback)
- [`onError`](#onerror)
- [`onReset`](#onreset)
- [`resetKeys`](#resetkeys)
- [`onResetKeysChange`](#onresetkeyschange)
- [`useErrorHandler(error?: Error)`](#useerrorhandlererror-error)
- [Issues](#issues)
- [🐛 Bugs](#-bugs)
- [💡 Feature Requests](#-feature-requests)
Expand Down Expand Up @@ -190,13 +183,13 @@ specific scenario.

### `ErrorBoundary` props

### `children`
#### `children`

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

### `FallbackComponent`
#### `FallbackComponent`

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

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

### `fallbackRender`
#### `fallbackRender`

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

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

### `fallback`
#### `fallback`

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

### `onError`
#### `onError`

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

### `onReset`
#### `onReset`

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

### `resetKeys`
#### `resetKeys`

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

See the recovery examples above.

### `onResetKeysChange`
#### `onResetKeysChange`

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

### `useErrorHandler(error?: Error)`

React's error boundaries feature is limited in that the boundaries can only
handle errors thrown during React's lifecycles. To quote
[the React docs on Error Boundaries](https://reactjs.org/docs/error-boundaries.html):

> Error boundaries do not catch errors for:
>
> - Event handlers
> ([learn more](https://reactjs.org/docs/error-boundaries.html#how-about-event-handlers))
> - Asynchronous code (e.g. setTimeout or requestAnimationFrame callbacks)
> - Server side rendering
> - Errors thrown in the error boundary itself (rather than its children)
This means you have to handle those errors yourself, but you probably would like
to reuse the error boundaries you worked hard on creating for those kinds of
errors as well. This is what `useErrorHandler` is for.

There are two ways to use `useErrorHandler`:

1. `const handleError = useErrorHandler()`: call `handleError(theError)`
2. `useErrorHandler(error)`: useful if you are managing the error state yourself
or get it from another hook.

Here's an example:

```javascript
function Greeting() {
const [greeting, setGreeting] = React.useState(null)
const handleError = useHandleError()

function handleSubmit(event) {
event.preventDefault()
const name = event.target.elements.name.value
fetchGreeting(name).then(
newGreeting => setGreeting(newGreeting),
handleError,
)
}

return greeting ? (
<div>{greeting}</div>
) : (
<form onSubmit={handleSubmit}>
<label>Name</label>
<input id="name" />
<button type="submit" onClick={handleClick}>
get a greeting
</button>
</form>
)
}
```

> Note, in case it's not clear what's happening here, you could also write
> `handleClick` like this:
```javascript
function handleSubmit(event) {
event.preventDefault()
const name = event.target.elements.name.value
fetchGreeting(name).then(
newGreeting => setGreeting(newGreeting),
error => handleError(error),
)
}
```

Alternatively, let's say you're using a hook that gives you the error:

```javascript
function Greeting() {
const [name, setName] = React.useState('')
const {greeting, error} = useGreeting(name)
useHandleError(error)

function handleSubmit(event) {
event.preventDefault()
const name = event.target.elements.name.value
setName(name)
}

return greeting ? (
<div>{greeting}</div>
) : (
<form onSubmit={handleSubmit}>
<label>Name</label>
<input id="name" />
<button type="submit" onClick={handleClick}>
get a greeting
</button>
</form>
)
}
```

In this case, if the `error` is ever set to a truthy value, then it will be
propagated to the nearest error boundary.

In either case, you could handle those errors like this:

```javascript
const ui = (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Greeting />
</ErrorBoundary>
)
```

And now that'll handle your runtime errors as well as the async errors in the
`fetchGreeting` or `useGreeting` code.

## Issues

_Looking to contribute? Look for the [Good First Issue][good-first-issue]
Expand Down
4 changes: 4 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ export function withErrorBoundary<P>(
ComponentToDecorate: React.ComponentType<P>,
errorBoundaryProps: ErrorBoundaryProps,
): React.ComponentType<P>

export function useErrorHandler<P = Error>(
error?: P,
): React.Dispatch<React.SetStateAction<P>>
103 changes: 103 additions & 0 deletions src/__tests__/hook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React from 'react'
import userEvent from '@testing-library/user-event'
import {render, screen} from '@testing-library/react'
import {ErrorBoundary, useErrorHandler} from '..'

function ErrorFallback({error, componentStack, resetErrorBoundary}) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<pre>{componentStack}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
}

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

test('handleError forwards along async errors', async () => {
function AsyncBomb() {
const [explode, setExplode] = React.useState(false)
const handleError = useErrorHandler()
React.useEffect(() => {
if (explode) {
setTimeout(() => {
handleError(new Error('💥 CABOOM 💥'))
})
}
})
return <button onClick={() => setExplode(true)}>bomb</button>
}
render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<AsyncBomb />
</ErrorBoundary>,
)

userEvent.click(screen.getByRole('button', {name: /bomb/i}))

await screen.findByRole('alert')

const [[actualError], [componentStack]] = console.error.mock.calls
const firstLineOfError = firstLine(actualError)
expect(firstLineOfError).toMatchInlineSnapshot(
`"Error: Uncaught [Error: 💥 CABOOM 💥]"`,
)
expect(componentStack).toMatchInlineSnapshot(`
"The above error occurred in one of your React components:
in Unknown
in ErrorBoundary
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary."
`)
expect(console.error).toHaveBeenCalledTimes(2)
console.error.mockClear()

// can recover
userEvent.click(screen.getByRole('button', {name: /try again/i}))
expect(console.error).not.toHaveBeenCalled()
})

test('can pass an error to useErrorHandler', async () => {
function AsyncBomb() {
const [error, setError] = React.useState(null)
const [explode, setExplode] = React.useState(false)
useErrorHandler(error)
React.useEffect(() => {
if (explode) {
setTimeout(() => {
setError(new Error('💥 CABOOM 💥'))
})
}
})
return <button onClick={() => setExplode(true)}>bomb</button>
}
render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<AsyncBomb />
</ErrorBoundary>,
)

userEvent.click(screen.getByRole('button', {name: /bomb/i}))

await screen.findByRole('alert')
const [[actualError], [componentStack]] = console.error.mock.calls
const firstLineOfError = firstLine(actualError)
expect(firstLineOfError).toMatchInlineSnapshot(
`"Error: Uncaught [Error: 💥 CABOOM 💥]"`,
)
expect(componentStack).toMatchInlineSnapshot(`
"The above error occurred in one of your React components:
in Unknown
in ErrorBoundary
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary."
`)
expect(console.error).toHaveBeenCalledTimes(2)
console.error.mockClear()

// can recover
userEvent.click(screen.getByRole('button', {name: /try again/i}))
expect(console.error).not.toHaveBeenCalled()
})
14 changes: 0 additions & 14 deletions src/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,6 @@ function Bomb() {

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

beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
try {
expect(console.error).not.toHaveBeenCalled()
} catch (e) {
throw new Error(
`console.error was called unexpectedly (make sure to assert all calls and console.error.mockClear() at the end of the test)`,
)
}
})

test('standard use-case', async () => {
function App() {
const [username, setUsername] = React.useState('')
Expand Down
9 changes: 8 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,11 @@ function withErrorBoundary(Component, errorBoundaryProps) {
return Wrapped
}

export {ErrorBoundary, withErrorBoundary}
function useErrorHandler(givenError) {
const [error, setError] = React.useState(null)
if (givenError) throw givenError
if (error) throw error
return setError
}

export {ErrorBoundary, withErrorBoundary, useErrorHandler}

0 comments on commit 3c93397

Please sign in to comment.