An illustration with the text: Yo, do you even lift? Yo, do you even lift?

React State Management


TLDR: Extract state from components, don’t lift it up.


I have a love-hate relationship with React. I often think it is a wonderful library, making it easy to get started with something. Before you know it, you’ve gone down a rabbit hole and have built something marvelous.

But then there are times when I think things are weird, counter-intuitive, and unnecessarily hard. One of those things is state management.

Let’s imagine we have states that we want to share/manage between components to separate concerns and simplify each component. There are multiple ways to achieve this. Here is an interactive example:

Root

(has been rendered 1 time)

BranchA: useState

(has been rendered 1 time)

LeaveAA

(has been rendered 1 time)

X = 1

LeaveAB

(has been rendered 1 time)

BranchB: zustand

(has been rendered 1 time)

LeaveBA

(has been rendered 1 time)

Y = 1

LeaveBB

(has been rendered 1 time)

Example 1, source: GitHub

Example 1 simplified code (without styling or rendering counter)
function Root() {
  return (
    <>
      <h2>Root</h2>
      <BranchA/>
      <BranchB/>
    </>
  );
}

function BranchA() {
  const [counterA, setCounterA] = useState(1);
  
  return (
    <>
      <h3>
        BranchA: <i>useState</i>
      </h3>
      <LeaveAA counterA={counterA}/>
      <LeaveAB setCounterA={setCounterA}/>
    </>
  );
}

function LeaveAA({ counterA }: { counterA: number }) {
  return (
    <>
      <h4>LeaveAA</h4>
      <p>X = {counterA}</p>
    </>
  );
}

function LeaveAB({
  setCounterA,
}: {
  setCounterA: React.Dispatch<React.SetStateAction<number>>;
}) {
  return (
    <>
      <h4>LeaveAB</h4>
      <button onClick={() => setCounterA((counterA) => counterA * 2)}>
        X*2
      </button>
    </>
  );
}

function BranchB() {
  return (
    <>
      <h3>
        BranchB: <i>zustand</i>
      </h3>
      <LeaveBA/>
      <LeaveBB/>
    </>
  );
}

const useStore = create((set) => ({
  counterB: 1,
  increaseB: () => set((state) => ({counterB: state.counterB * 2})),
}));

function LeaveBA() {
  const counterB = useStore((state) => state.counterB);
  
  return (
    <>
      <h4>LeaveBA</h4>
      <p>Y = {counterB}</p>
    </>
  );
}

function LeaveBB() {
  const increaseB = useStore((state) => state.increaseB);
  
  return (
    <>
      <h4>LeaveBB</h4>
      <button onClick={() => increaseB()}>Y*2</button>
    </>
  );
}

The standard, recommended way is to use the useState hook and lift the state up to a common ancestor. Then, you can prop-drill (or use a context) to pass the state down to the components that need it.

This is implemented in the component BranchA. The state is declared in BranchA, and its value is passed down as a prop to LeaveAA and the setter function to LeaveAB. However, if you click on the X*2 button, you’ll notice that not only the component LeaveAA, where the value is used, is re-rendered1, but also LeaveAB and BranchA.

Now, let’s take a look at BranchB. Here, we use the zustand library to manage the state. We declare the state outside our components and inject it into the components that need it. This means that only the LeaveBA receives the state’s value, while the LeaveBB receives the setter function. A significant difference! Thanks to that, only the component LeaveBA is re-rendered when clicking on the Y*2 button.

Let’s look at another example:

Root

(has been rendered 1 time)

BranchA

(has been rendered 1 time)

LeaveAA

(has been rendered 1 time)

X = 1

LeaveAB

(has been rendered 1 time)

Y = 1

BranchB

(has been rendered 1 time)

LeaveBA

(has been rendered 1 time)

LeaveBB

(has been rendered 1 time)

Example 2, source: GitHub

Example 2 simplified code (without styling or rendering counter)
function Root() {
  const [counterA, setCounterA] = useState(1);

  return (
    <>
      <h2>Root</h2>
      <BranchA counterA={counterA} />
      <BranchB setCounterA={setCounterA} />
    </>
  );
}

function BranchA({ counterA }: { counterA: number }) {
  return (
    <>
      <h3>BranchA</h3>
      <LeaveAA counterA={counterA} />
      <LeaveAB />
    </>
  );
}

function LeaveAA({ counterA }: { counterA: number }) {
  return (
    <>
      <h4>LeaveAA</h4>
      <p>X = {counterA}</p>
    </>
  );
}

const useStore = create((set) => ({
  counterB: 1,
  increaseB: () => set((state) => ({ counterB: state.counterB * 2 })),
}));

function LeaveAB() {
  const counterB = useStore((state) => state.counterB);

  return (
    <>
      <h4>LeaveAB</h4>
      <p>Y = {counterB}</p>
    </>
  );
}

function BranchB({
  setCounterA,
}: {
  setCounterA: React.Dispatch<React.SetStateAction<number>>;
}) {
  return (
    <>
      <h3>BranchB</h3>
      <LeaveBA setCounterA={setCounterA} />
      <LeaveBB />
    </>
  );
}

function LeaveBA({
  setCounterA,
}: {
  setCounterA: React.Dispatch<React.SetStateAction<number>>;
}) {
  return (
    <>
      <h4>LeaveBA</h4>
      <button onClick={() => setCounterA((counterA) => counterA * 2)}>
        X*2
      </button>
    </>
  );
}

function LeaveBB() {
  const increaseB = useStore((state) => state.increaseB);

  return (
    <>
      <h4>LeaveBB</h4>
      <button onClick={() => increaseB()}>Y*2</button>
    </>
  );
}

Here, the components that read the state (LeaveAA, LeaveAB) are further away from the components that update the state (LeaveBA, LeaveBB). So we lifted the state up one level further, to the Root component and prop-drilled our value/setter down to the leaves. And now, the difference: when you click on X*2, ALL components are re-rendered. However, when you click on Y*2, still only LeaveBA is re-rendered.

Now, imagine having a large application with intricate components, intricate states and intricate effects. Being able to selectively re-render only the components that need to update, without relying on additional features like memo, is a significant improvement. Simultaneously, it enhances readability (in my opinion) by separating concerns and breaking down complexity into smaller, more understandable/manageable pieces.

That leaves me to wonder, why is this not the standard way of doing things?


Footnotes:

Footnotes

  1. Rendered in the VDOM. The actual DOM will only be updated after reconciliation.