React router 6 side-effect clean-up on unmount

Viewed 1018

I have a problem with react-router after I migrated to v6. The problem is when a router is used to render components that do side-effects and should do clean-up on unmount. In the v6 case, I don't get the component to run its clean-up function. I assume this is by design, but I would like to know how/can I model the v5 logic in v6.

I have two minimal apps that show my issue:

In both apps, the idea is that when you navigate to a page, it renders a component that modifies the DOM and removes the DOM elements it adds on unmount. This doesn't happen in the v6 case and the added DOM elements are kept in the DOM and they start to stack up:

2 Answers

Please don't do Imperative work with React Declarative structure, it might bite you some times like this, (and ofcourse by imperative I mean adding innerText in useEffect).

A few points here to note are:

  • it's not about react-router you're using React useEffect and it's calling you're effects and clean up functions the same ( as you can see by checking the console and seeing that modifying and unmounting are the same between two projects.

  • the second point is your cleanup function, besides console.log('unmounting: ', name);, it doesn't do anything, because in both projects the container is null.

const container = document.getElementById(name); //container is null
container?.parentNode?.removeChild(container);

the difference between the two projects is that in v6 we use Outlet component and it's keeping the text that you're adding imperatively.

but if you were to use any other effect which needs an unsubscribe or cleanup you can do it perfectly in both versions.

in summary, both versions are working fine but the way you are adding or removing text is wrong, you could try other ways like showing or hiding a text based on a state which is more Reacty

useEffect with dependencies should run its cleanup after the render, so it's actually proper behavior.

To run cleanup on unmount, use useEffect with an empty dependencies array

React.useEffect(() => {
    // Side effect where we modify dom
    console.log('modifying: ', name);
    const element = document.createElement('span');
    element.innerText = `This text was added as a side-effect for component: ${name}`;
    const container = document.getElementById(name);
    container?.appendChild(element);

    // Clean-up on unmount
    return () => {
      console.log('unmounting: ', name);
      const container = document.getElementById(name);
      container?.parentNode?.removeChild(container);
    };
  }, []);

But if you want to achieve same (invalid) behavior as with react-router v5, you have to force router to unnecessarily re-mount same component every time its prop is changed.

This is not recommended but here you go:

const pages = [...Array(3).keys()].map((x) => (
    <Route
      key={`route-${x}`}
      path={`component-${x}`}
      element={<Component key={`component-${x}`} name={`component-${x}`} />}
    />
  ));

(unique key prop will force react to not reuse same component)

Explanation to useEffect:

  1. useEffect always runs after a render
  2. previous useEffect is always cleaned up before running new useEffect

So the first is render, then useEffect with new dependency value wants to run, so the previous useEffect with old value must be cleaned up.

Example

interface FooProps {
  id: string
}

const Foo: React.FC<FooProps> = ({ id }) => {
  React.useEffect(() => {
    console.log({ id })

    return () => {
      console.log({ id })
    }
  }, [id]);

  return <div id={id} />
}

Order of events when this component is used:

  1. id starts as 'a'
  2. div with id 'a' is rendered
  3. useEffect runs with its dependency id set to 'a'
  4. Foo receives property id with value changed to 'b'
  5. div with id 'b' is rendered instead of div with id 'a'
  6. useEffect runs cleanup with its dependency id set to 'a'
  7. useEffect runs with dependency id set to 'b'

So to make this absolutely clear:

useEffect(() => {
  return () => {
    // This cleanup runs only on unmount
  }
}, []);

useEffect(() => {
  return () => {
    // This cleanup runs right before this useEffect runs with updated name value
  }
}, [name]);
Related