learn react ui logoLearnReactUI
Zustand ReRender Cases

Zustand Re-Render Example

Components are re-rendered when states, such as Zustand or other ClientState or ServerState libraries, are updated.

Why are we considering this re-rendering if the components' time spent on it is ms?

The reason for this is that re-rendering also initiates fetching and subscribing activities in the components once more. → Because these actions need the use of a network or web-api, they significantly slow down the system and result in needless resource utilization, making it complicated and untraceable.

Avoiding rendering components again until absolutely required should be one of your top priorities.

[Demo]

1. What is a Zustand ?

Zustand is a lightweight and flexible state management library for React, offering a simple API with minimal boilerplate. Unlike complex solutions like Redux, Zustand eliminates the need for reducers and actions, allowing developers to manage state with a straightforward, functional approach.

It leverages React’s context but avoids unnecessary re-renders by using a global store with selective subscription updates.

Zustand supports asynchronous actions, middleware, and even React Server Components, making it an excellent choice for managing global or shared states efficiently. Its ease of use and performance optimizations make it a popular alternative for modern React applications.

In the examples below, different situations that trigger different component re-renders are tried.

2. Zustand Re-Render Development using KnowledgeMap

In this KnowlegeMap we will step-by-step complexify the use of Zustand Store and Components and analyse how they react to re-rendering.

Kanban Board

Zustand1: The first example is a one-component Counter that holds a number value.

Zustand2: In the 2nd example, we increase the number of components to 3 and the number of stored counters to 2. We try this with Default, Shallow and Atomic methods

Zustand3: The 3rd example was developed to compare the one-to-one behaviour of Shallow and Atomic.

Zustand4: The 4th example includes array, object, function and their update, simulating complex store states.

Note: Because the project codes for the examples below will be available for download at LearnReactUI.dev, I will simply highlight and discuss the most significant sections of the code.

3. Examples

3.1. Zustand1

In this example, Zustand Store keeps a single Count state. Inside the Zustend Counter;

  • A pink box. This box starts spinning when Re-Rendering
  • A component showing the number of counts
  • Buttons to increase and decrease the Count value.

Zustand2

When you examine the picture above, each increase and decrease in the Count state will trigger the component to re-rendering again. Because we show the Count value in it.

3.2. Zustand2

In this example, default, atomic and shallow copy values are compared to see how they affect re-rendering.

In this example, in Store

  • We keep 2 Count states. Count1 and Count2
  • There are 3 components. Comp1, Comp2, Comp2

Kanban Board Dnd

The behaviour we expect to happen. While increasing Count1 by pressing Counter1 in Comp1 triggers the re-render of 3 components, increasing Count2 in Comp2 triggers the re-render of only Comp1 and Comp2.

Default: There is a circumstance in this example that does not go as planned. A re-render is triggered by Counter2 → Comp3.

Default

First we generate Zustand Store, it holds count1, count2

import { create } from "zustand";

export const useSampleStore = create((set) => ({
  count1: 0,
  count2: 0,
  increase1: () => set((state) => ({ count1: state.count1 + 1 })),
  increase2: () => set((state) => ({ count2: state.count2 + 1 })),
}));

Comp3 use this zustand store.

import React from "react";

import { useSampleStore } from "./store";

function Comp3() {
  const { count1 } = useSampleStore();
  console.log("Comp3 render");
  return (
    <>
      <div>Comp3</div>
      <span>{new Date().getMilliseconds()}</span>
      <div>count 1 :{count1}</div>
    </>
  );
}

export default Comp3;

Shallow

useShallow compare between store values and prevent the store props used in the component from triggering re-render.

Shallow

import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";

export const useSampleStore = create((set) => ({
  count1: 0,
  count2: 0,
  increase1: () => set((state) => ({ count1: state.count1 + 1 })),
  increase2: () => set((state) => ({ count2: state.count2 + 1 })),
}));

export const useSampleStore2 = () =>
  useSampleStore(
    useShallow((state) => ({
      count1: state.count1,
      count2: state.count2,
      increase1: state.increase1,
      increase2: state.increase2,
    }))
  );

As can be seen in the example below, the count1 taken from useSampleStore2 derived from useShallow does not re-render for Comp3 because there is no value change.

import React from "react";

import { useSampleStore2 } from "./store";

function Comp3Shallow() {
  const { count1 } = useSampleStore2();
  console.log("Comp3 render");
  return (
    <>
      <div>Comp3 Shallow</div>
      <div>count 1 :{count1}</div>
    </>
  );
}

export default Comp3Shallow;

Atomic Working with Zustand to make Store Atomic to use in the way you can find the details of this topic.

Atomic

So how can we realise this as Zustand atomic? Store can make useCount1, useCount2 in many sub-fractured atomic structures as follows.

import { create } from "zustand";

export const useSampleStore = create((set) => ({
  count1: 0,
  count2: 0,
  increase1: () => set((state) => ({ count1: state.count1 + 1 })),
  increase2: () => set((state) => ({ count2: state.count2 + 1 })),
}));

export const useCount1 = () => useSampleStore((state) => state.count1);
export const useCount2 = () => useSampleStore((state) => state.count2);

In this case Comp3 will only make an atomic connection with useCount1,

import React from "react";

import { useCount1 } from "./store";

function Comp3Atomic() {
  const count1 = useCount1();
  console.log("Comp3 render");
  return (
    <>
      <div>Comp3 Atomic</div>
      <div>count 1 :{count1}</div>
    </>
  );
}

export default Comp3Atomic;

3.3. Zustand3

In this example, I have set up 2 setups where we can exactly compare how Shallow and Atomic structures work.

  • Shallow components: Comp1, Comp2
  • Atomic components: Comp3, Comp4

AtomicVsShallow

In this example, Shallow 2 triggers re-rendering of the component. That’s not the situation we want. Atomic is the one that works correctly

AtomicVsShallow2

In this case Inc1 in Comp1 → Comp2 triggers, Inc2 in Comp2 → Comp1 triggers. But you will see that this does not work in Shallow. This is because Inc1 and Inc2 methods are in the store, so Shallow does not work here.

//Shallow
export const useSampleSlice = create((set, get) => ({
  count1: 0,
  count2: 0,
  increase1: () =>
    set((state) => {
      console.log("increase1");
      return { count1: state.count1 + 1 };
    }),
  increase2: () =>
    set((state) => {
      console.log("increase2");
      return { count2: state.count2 + 1 };
    }),
}));

export const useSampleStore = () =>
  useSampleSlice(
    useShallow((state) => ({
      count1: state.count1,
      count2: state.count2,
      increase1: state.increase1,
      increase2: state.increase2,
    }))
  );

In the atomic method, i.e. Comp3 and Comp4, the similar situation works exactly as desired.

//Atomic
export const useSampleSlice2 = create((set, get) => ({
  count1: 0,
  count2: 0,
  increase1: () =>
    set((state) => {
      console.log("increase1");
      return { count1: state.count1 + 1 };
    }),
  increase2: () =>
    set((state) => {
      console.log("increase2");
      return { count2: state.count2 + 1 };
    }),
}));

export const useCount1 = () => useSampleSlice2((state) => state.count1);
export const useCount2 = () => useSampleSlice2((state) => state.count2);
export const useIncrease1 = () => useSampleSlice2((state) => state.increase1);
export const useIncrease2 = () => useSampleSlice2((state) => state.increase2);

Atomic structure solves the re-rendering caused by the function here, but to make it atomic, it is necessary to make all uses atomic one by one and ask the components to use these atomic functions.

The disadvantage here is that in the atomic structure, the Client needs to choose from many atomic structures, which means that the API is more difficult to read and understand.

3.4. Zustand4

In this example we make State more complex. In State;

  • Count1, Count2 value we keep the number.
  • The list is an Array and we update this List or update an Object (add)
  • We update the props of Filter object.

AtomicVsShallow2

Firstly, let's analyse our store structure.

const useSampleStoreSlice = (set, get) => ({
  //primitive
  count1: 0,
  count2: 0,

  //static object
  filterInputs: {
    name: "Empty",
    no: 0,
  },

  //dynamic object
  handle: { abc: "23123" },

  //functions
  increase1: () => set((state) => ({ count1: state.count1 + 1 })),
  increase2: () => set((state) => ({ count2: state.count2 + 1 })),
  setFilterInputs: (filterObj) => {
    set({ filterInputs: { ...filterObj } });
  },

  setHandle: (mode) => {
    const p = get().handle;
    p[mode + ""] = uuidv4();
    set({ handle: { ...p } });
  },
});

There are count1, count2 values as primitives in the Store structure, which we have examined in the previous examples

Secondly, there is a static object holding filterInputs

Thirdly, there is a handle that holds the commands sent as dynamic objects in the form of key, value.

At the bottom of the store structure, we have functions that can update the above values.

Now let's create a store.

const useSampleStore = create(
  devtools(useSampleStoreSlice, {
    name: "ZustandSample4Store",
    serialize: { options: true },
  })
);

If we use this store via useSampleStore, I have explained in my previous blog posts how the re-render states of the components cause problems. In the previous example, I mentioned that Atomic structure is more advantageous than Shallow.

When we make each thing atomic API ... useCount1, useCount2 ... the terminology we use in each component increases according to the store complexity.

// export const useCount1 = () => useSampleStore((state) => state.count1);
// export const useCount2 = () => useSampleStore((state) => state.count2);
// export const useHandle = () => useSampleStore((state) => state.handle);
// export const useHandleSelector = (prop) => useSampleStore((state) => state.handle[prop]);
// ....

In this case, how can we make it more abstracted and reusable. By writing our own selector.

export const useSampleStoreSelector = (selector) => {
  return useSampleStore((state) => {
    return selector.split(".").reduce((acc, part) => acc && acc[part], state);
  });
};