skip to Main Content

I have an app that has two tabs "Apple" and "Banana". Each tab has a counter that is implemented with useState.

const Tab = ({ name, children = [] }) => {
  const id = uuid();
  const [ count, setCount ] = useState(0);

  const onClick = e => {
    e.preventDefault();
    setCount(c => c + 1);
  };

  const style = {
    background: "cyan",
    margin: "1em",
  };

  return (
    <section style={style}>
      <h2>{name} Tab</h2>
      <p>Render ID: {id}</p>
      <p>Counter: {count}</p>
      <button onClick={onClick}>+1</button>
      {children}
    </section>
  );
};

What is confusing is that the counter state is shared between both tabs!

If I increment the counter on one tab and then switch to the other tab, the counter has changed there too.

Why is this?


Here is my complete app:

import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import { v4 as uuid } from "uuid";
import { HashRouter as Router, Switch, Route, Link } from "react-router-dom";

const Tab = ({ name, children = [] }) => {
  const id = uuid();
  const [ count, setCount ] = useState(0);

  const onClick = e => {
    e.preventDefault();
    setCount(c => c + 1);
  };

  const style = {
    background: "cyan",
    margin: "1em",
  };

  return (
    <section style={style}>
      <h2>{name} Tab</h2>
      <p>Render ID: {id}</p>
      <p>Counter: {count}</p>
      <button onClick={onClick}>+1</button>
      {children}
    </section>
  );
};

const App = () => {
  const id = uuid();

  return (
    <Router>
      <h1>Hello world</h1>
      <p>Render ID: {id}</p>
      <ul>
        <li>
          <Link to="/apple">Apple</Link>
        </li>
        <li>
          <Link to="/banana">Banana</Link>
        </li>
      </ul>
      <Switch>
        <Route
          path="/apple"
          exact={true}
          render={() => {
            return <Tab name="Apple" />;
          }}
        />
        <Route
          path="/banana"
          exact={true}
          render={() => {
            return <Tab name="Banana" />;
          }}
        />
      </Switch>
    </Router>
  );
};

const container = document.getElementById("root");
const root = createRoot(container);

root.render(<App />);

Versions:

  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router": "5.2.1",
    "react-router-dom": "5.2.1",
    "uuid": "^9.0.0"
  },

2

Answers


  1. It has to do with the way Switch works in react-router-dom

    Ultimately, your tree of components remains identical, even when you switch routes.

    It’s always a Router -> Switch -> Route -> Tab

    Because of the way Switch works, React never "mounts" a new component, it just reuses the old tree, because it can.

    I’ve run into this exact issue before, the fix is to add a key somewhere, like on the Tab or the Route. I usually add it to the Route because it makes more sense in my mind:

          <Switch>
            <Route
              key={'apple'}
              path="/apple"
              exact={true}
              render={() => {
                return <Tab name="Apple" />;
              }}
            />
            <Route
              key={'banana'}
              path="/banana"
              exact={true}
              render={() => {
                return <Tab name="Banana" />;
              }}
            />
          </Switch>
    

    Check this stackblitz:

    https://stackblitz.com/edit/react-gj5mcv?file=src/App.js

    Of course, your state gets reset in each tab when they get unmounted, which may or may not be desirable. But the solution to that, of course (if it’s a problem for you), is, as usual, to lift state up.

    Login or Signup to reply.
  2. Adam has a great explanation and answer for what is happening here and that it is an optimization to not tear down and remount the same React component simply because the URL path changed. Using a React key will certainly solve this by forcing React to remount the Tab component and thus "reset" the count state.

    I’d suggest another method that keeps the routed component mounted and simply resets the count state when the name prop changes from "apple" to "banana" and vise-versa.

    const Tab = ({ name, children = [] }) => {
      const id = uuid();
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        setCount(0); // <-- reset to 0 when name prop updates
      }, [name, setCount]);
    
      const onClick = e => {
        e.preventDefault();
        setCount(c => c + 1);
      };
    
      const style = {
        background: "cyan",
        margin: "1em",
      };
    
      return (
        <section style={style}>
          <h2>{name} Tab</h2>
          <p>Render ID: {id}</p>
          <p>Counter: {count}</p>
          <button onClick={onClick}>+1</button>
          {children}
        </section>
      );
    };
    

    This will keep the RRD optimization working for you instead of against you.

    If you didn’t have a passed prop like name to take a cue from, then location.pathname could be used. Note that this does couple some internal component logic to external details.

    Example:

    const { pathname } = useLocation();
    const [count, setCount] = useState(0);
    
    useEffect(() => {
      setCount(0);
    }, [pathname, setCount]);
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search