Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Error Boundaries disables routing inside of a Switch

For a long time I have been trying to get routing to work in our app after an error boundary has been hit, but only today did I find the code that was seemingly identical to the many examples lying around had one important difference: the routes were wrapped by a Switch. This simple change is enough to stop routing from working, if enabled. Demo

Take the following snippet. If I remove the Switch bit, this works fine even if each component should fail, but not if wrapped by the switch. I would like to know why.

<div style={{ backgroundColor: "#ffc993", height: "150px" }}>
<Switch>
  <Route
    path="/"
    exact
    render={() => (
      <ErrorBoundary>
        <MyComponent1 title="Component 1" />
      </ErrorBoundary>
    )}
  />
  <Route
    path="/comp1"
    render={() => (
      <ErrorBoundary>
        <MyComponent1 title="Component 1 Again" />
      </ErrorBoundary>
    )}
  />
  <Route
    path="/comp2"
    render={() => (
      <ErrorBoundary>
        <MyComponent2 title="Component 2" />
      </ErrorBoundary>
    )}
  />
</Switch>
like image 729
oligofren Avatar asked Dec 23 '22 20:12

oligofren


2 Answers

Basically, this problem boils down to how React does reconciliation.

When a component updates, the instance stays the same, so that state is maintained across renders. React updates the props of the underlying component instance to match the new element

Say we have this example app:

<App>
  <Switch>
    <Route path="a" component={Foo}/>
    <Route path="b" component={Foo}/>
  </Switch>
</App> 

This will, somewhat unintuitively, reuse the same instance of Foo for both routes! A <Switch> will always return the first matched element, so basically when React renders this is equivalent of the tree <App><Foo/></App> for path "a" and <App><Foo/></App> for path "b". If Foo is a component with state, that means that state is kept, as the instance is just passed new props (for which there are none, except children, in our case), and is expected to handle this by recomputing its own state.

As our error boundary is being reused, while it has state that has no way of changing, it will never re-render the new children of its parent route.

React has one trick hidden up its sleeve for this, which I have only seen explicitly documented on its blog:

In order to reset the value when moving to a different item (as in our password manager scenario), we can use the special React attribute called key. When a key changes, React will create a new component instance rather than update the current one. (...) In most cases, this is the best way to handle state that needs to be reset.

I was first hinted to this by a somewhat related issue on Brian Vaughn's error bondary package:

The way I would recommend resetting this error boundary (if you really want to blow away the error) would be to just clear it out using a new key value. (...) This will tell React to throw out the previous instance (with its error state) and replace it with a new instance.

The alternative to using keys would be to implement either exposing some hook that could be called externally or by trying to inspect the children property for change, which is hard. Something like this could work (demo):

componentDidUpdate(prevProps, prevState, snapshot) {
    const childNow = React.Children.only(this.props.children);
    const childPrev = React.Children.only(prevProps.children);

    if (childNow !== childPrev) {
        this.setState({ errorInfo: null });
   }

But it's more work and much more error prone, so why bother: just stick to adding a key prop :-)

like image 60
oligofren Avatar answered Jan 13 '23 12:01

oligofren


To give you the shortcut of this fix, please see the new "key" prop on each of the ErrorBoundary component and each must be unique, so the code should look like this:

<Switch>
  <Route
    path="/"
    exact
    render={() => (
      <ErrorBoundary key="1">
        <MyComponent1 title="Component 1" />
      </ErrorBoundary>
    )}
  />
  <Route
    path="/comp1"
    render={() => (
      <ErrorBoundary key="2">
        <MyComponent1 title="Component 1 Again" />
      </ErrorBoundary>
    )}
  />
  <Route
    path="/comp2"
    render={() => (
      <ErrorBoundary key="3">
        <MyComponent2 title="Component 2" />
      </ErrorBoundary>
    )}
  />
</Switch>

To elaborate, the answer of @oligofren is correct. Those 3 ErrorBoundary components are the same instances but may differ in props. You can verify this by passing "id" prop to each of the ErrorBoundary components.

Now you mentioned why is that if you remove Switch component, it works as expected? Because of this code: https://github.com/ReactTraining/react-router/blob/e81dfa2d01937969ee3f9b1f33c9ddd319f9e091/packages/react-router/modules/Switch.js#L40

I recommend you to read the official documentation of React.cloneElement here: https://reactjs.org/docs/react-api.html#cloneelement

I hope this gives you an idea on this issue. Credit to @oligofren as he explained in more details about the idea of the instances of those components.

like image 30
Jojo Tutor Avatar answered Jan 13 '23 11:01

Jojo Tutor