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.
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.
In this KnowlegeMap we will step-by-step complexify the use of Zustand Store and Components and analyse how they react to re-rendering.
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.
In this example, Zustand Store keeps a single Count state. Inside the Zustend Counter;
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.
In this example, default, atomic and shallow copy values are compared to see how they affect re-rendering.
In this example, in Store
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.
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.
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.
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;
In this example, I have set up 2 setups where we can exactly compare how Shallow and Atomic structures work.
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
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.
In this example we make State more complex. In State;
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);
});
};