One of the common challenges we face when using React with global state management is optimizing component re-renders. As our application grows, even small inefficiencies in how components re-render can significantly affect performance and the user experience.
In this blog, I’ll share my experiences and conclusion with Redux, React Context, and Zustand— focusing on how each handles re-renders and optimizes performance. Also, I will also be using the React Scan library to analyze which component re-renders.
Redux / Redux toolkit
Redux is one of the most widely used state management libraries for React. Let’s set up a basic Redux state and see how it triggers re-renders in the components that use its state.
import {createSlice, PayloadAction} from "@reduxjs/toolkit";
interface BasicSliceInterface {
name: string
email: string
}
// INITIAL REDUX STATE
const initialState: BasicSliceInterface = {
name: '',
email: ''
}
/**
* BASIC SLICE
*/
const BasicSlice = createSlice({
name: 'basic',
initialState,
reducers: {
setName(state, action: PayloadAction<string>) {
state.name = action.payload
},
setEmail(state, action: PayloadAction<string>) {
state.email = action.payload
}
}
})
export default BasicSlice
Here is a basic example of a redux state with an initial state and reducer actions that updates individual values of the state.
The way a component re-renders depends on how the Redux state is used. Let’s consider two components that handle displaying and updating the name and email separately.
1. Name Component
import React from "react";
import {useDispatch, useSelector} from "react-redux";
import type {ReduxStoreState} from "../../../store";
import BasicSlice from "../../../store/basic.slice.ts";
const NameComponent: React.FC = () => {
/**
* REDUX SELECTOR
*/
const {name} = useSelector((store: ReduxStoreState) => {
return store?.basic
})
/**
* HOOKS
*/
const dispatch = useDispatch()
/**
* METHODS
*/
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(BasicSlice.actions.setName(e.target.value ?? ''))
}
return <>
<div className={"p-3 shadow-md border-[1px] border-grey-100 rounded-md flex flex-col gap-2"}>
<label className={'mb-2'}>Name</label>
<input type="text" className={'p-2 border-[1px] border-grey-300'} value={name} onChange={handleNameChange}/>
</div>
</>
}
export default NameComponent
2. Email Component
import React from "react";
import {useDispatch, useSelector} from "react-redux";
import type {ReduxStoreState} from "../../../store";
import BasicSlice from "../../../store/basic.slice.ts";
const EmailComponent: React.FC = () => {
/**
* REDUX SELECTOR
*/
const email = useSelector((store: ReduxStoreState) => {
return store?.basic?.email
})
/**
* HOOKS
*/
const dispatch = useDispatch()
/**
* METHODS
*/
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(BasicSlice.actions.setEmail(e.target.value ?? ''))
}
return <div className={"p-3 shadow-md border-[1px] border-grey-100 rounded-md flex flex-col gap-2"}>
<label className={'mb-2'}>Email</label>
<input type="text" className={'p-2 border-[1px] border-grey-300'} value={email} onChange={handleEmailChange}/>
</div>
}
export default EmailComponent
So, what is the difference between these two components? At first glance, they both subscribe to the Redux state and dispatch updates to the corresponding values back to Redux.
The clear difference is the use of the selectors.
const email = useSelector((store: ReduxStoreState) => {
return store?.basic?.email
})
const {name} = useSelector((store: ReduxStoreState) => {
return store?.basic
})
In the “Name component”, the entire basic Redux state is selected, whereas in the “Email component”, only the email key from the basic state is selected.
The issue with the “Name component” is that it re-renders whenever any value in the basic state changes — including email, even though email is not being used. In contrast, the “Email component” does not re-render because its selector only depends on the email key.
Thus, Selecting only the necessary part of the Redux state prevents unnecessary re-renders and improves performance. Avoid subscribing to the entire state when only a specific key is needed.
For more advance optimizations we can also use.
1. ShallowEqual
shallowEqual
(specifically from react-redux) is a utility function that performs a shallow comparison between two objects — it checks if the objects have the same keys and if the values of those keys are strictly equal (===).
const { name, email } = useSelector(
state => ({ name: state.basic.name, email: state.basic.email }),
shallowEqual
);
With shallowEqual, it skips re-rendering if basic.name and basic.email stay the same.
2. Re-select
Reselect is a library used with Redux to create memoized selectors. These selectors compute derived data from the Redux state and cache the result so they only recompute when the relevant input state changes.
import { createSelector } from 'reselect';
// Input selectors
const selectBasic = state => state.basic;
const selectEmail = state => selectBasic(state).email;
const selectName = state => selectBasic(state).name;
// Memoized selector to combine name and email
const selectUserInfo = createSelector(
[selectName, selectEmail],
(name, email) => ({ name, email })
);
If name or email changes, selectUserInfo recalculates. If neither changes, it returns the cached result, preventing unnecessary work.
This might not be the ideal example to demonstrate Reselect, but it truly shines when working with deeply nested state or large arrays, where recalculating derived data can be expensive.
React Context Api
The React Context API is a built-in feature in React that allows you to share data globally across the component tree — without passing props manually at every level.
In this blog, we’re not focusing on how the Context API works, but rather on how it causes re-renders in components that consume its state.
Let’s consider a similar example to the one we used with Redux.
import type {PropsWithChildren} from 'react'
import React, {createContext, useState} from "react"
interface BasicContextInterface {
name: string,
email: string,
setEmail: React.Dispatch<React.SetStateAction<string>>,
setName: React.Dispatch<React.SetStateAction<string>>
}
/**
* BASIC CONTEXT INITIALIZATION
*/
export const BasicContext = createContext<BasicContextInterface>({} as BasicContextInterface)
/**
*
* @param children
* @constructor
*/
export const BasicContextWrapper: React.FC<PropsWithChildren> = ({children}) => {
/**
* COMPONENT STATE
*/
const [name, setName] = useState('')
const [email, setEmail] = useState('')
return <>
<BasicContext.Provider value={{
name,
email,
setName,
setEmail
}}>
{children}
</BasicContext.Provider>
</>
}
Similarly ,
Name Component
import React, {useContext} from "react";
import {BasicContext} from "../../../store/basic.context.tsx";
const NameComponent: React.FC = () => {
/**
* HOOKS
*/
const {name, setName} = useContext(BasicContext)
/**
* METHODS
*/
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value ?? '')
}
return <div className={"p-3 shadow-md border-[1px] border-grey-100 rounded-md flex flex-col gap-2"}>
<label className={'mb-2'}>Name</label>
<input type="text" className={'p-2 border-[1px] border-grey-300'} value={name} onChange={handleNameChange}/>
</div>
}
export default NameComponent
and Email Component.
import React, {useContext} from "react";
import {BasicContext} from "../../../store/basic.context.tsx";
const EmailComponent: React.FC = () => {
/**
* HOOKS
*/
const {email, setEmail} = useContext(BasicContext)
/**
* METHODS
*/
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value ?? '')
}
return <div className={"p-3 shadow-md border-[1px] border-grey-100 rounded-md flex flex-col gap-2"}>
<label className={'mb-2'}>Email</label>
<input type="text" className={'p-2 border-[1px] border-grey-300'} value={email} onChange={handleEmailChange}/>
</div>
}
export default EmailComponent
In the case of the Context API, components that don’t subscribe to the context using the hook won’t re-render. However, all components that do consume the context will re-render whenever any part of the context state changes — even if they only depend on a single value and that value hasn’t changed.
So in our example, whether the name or email value changes, both the Name and Email components re-render.
If necessary we can avoid this by.
- By splitting the context (Using multiple context).
- By using libraries such as use-context-selector link.
Zustand
Definition — As From its official website.
Zustand is a small, fast, and scalable state management library for React. It provides a simple and intuitive API to manage global state without the boilerplate of Redux or the complexity of Context API.
So lets quickly setup a similar example using Zustand.
import {create} from "zustand/react";
/**
* STORE INTERFACE
*/
interface BasicStoreInterface {
name: string
email: string,
setName: (value: string) => void,
setEmail: (value: string) => void,
}
/**
* STORE INITIALIZATION
*/
export const useBasicStore = create<BasicStoreInterface>((set) => ({
name: '',
email: '',
setEmail: (email) => set({email}),
setName: (name) => set({name})
}))
Zustand is pretty straightforward compared to Redux and React Context 😅, but that’s a topic for another time.
Similarly Name and Email Component
import React from "react";
import {useBasicStore} from "../../../store/basicStore.ts";
const NameComponent: React.FC = () => {
/**
* HOOKS
*/
const {setName, name} = useBasicStore((store)=>store)
/**
* METHODS
*/
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value ?? '')
}
return <div className={"p-3 shadow-md border-[1px] border-grey-100 rounded-md flex flex-col gap-2"}>
<label className={'mb-2'}>Name</label>
<input type="text" className={'p-2 border-[1px] border-grey-300'} value={name} onChange={handleNameChange}/>
</div>
}
export default NameComponent
import React from "react";
import {useBasicStore} from "../../../store/basicStore.ts";
const EmailComponent: React.FC = () => {
/**
* REDUX SELECTOR
*/
const {setEmail, email} = useBasicStore(store=>store)
/**
* METHODS
*/
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value ?? '')
}
return <div className={"p-3 shadow-md border-[1px] border-grey-100 rounded-md flex flex-col gap-2"}>
<label className={'mb-2'}>Email</label>
<input type="text" className={'p-2 border-[1px] border-grey-300'} value={email} onChange={handleEmailChange}/>
</div>
}
export default EmailComponent
But from the example above, it’s surprising that the re-rendering behavior is similar to the React Context API — even though the Name component doesn’t depend on email, and vice versa, both components are still being re-rendered.
So why are we facing this issue?
The issue is with how we are using the selectors.
const {setEmail, email} = useBasicStore(store=>store)
Here we are subscribing to the entire store object (store => store), which means every time any part of the store changes (even if it’s not email), this component will re-render. (Similar to redux issue).
We can fix this in 2 ways.
- By proper use of Zustand selectors.
const email = useBasicStore(state => state.email);
const setEmail = useBasicStore(state => state.setEmail);
- If we are selecting multiple values, we can use useShallow function from Zustand to avoid re-renders if values are the same.
import {useShallow} from "zustand/react/shallow";
const {setEmail, email} = useBasicStore(useShallow(store => {
const {setEmail, email} = store
return {setEmail, email}
}))
Here, useShallow is just a shallow comparison function (not a hook by itself). It’s used as the equality function in your useStore hook.
Now the issue is resolved.
Conclusion
Managing global state efficiently is critical as your app grows. While tools like Redux, Context API, and Zustand make sharing state easy, careless usage can lead to unnecessary re-renders and degraded performance.
Redux: Use selectors smartly. Subscribing to just the necessary slice of state (not the whole object) and using tools like reselect and shallowEqual can prevent wasteful renders.
Context API: Simple but coarse-grained. Any change in context causes all consumers to re-render — even if they don’t rely on the changed value.
Zustand: Cleaner and more ergonomic than Redux or Context, but you still need to select only what you need and optionally use useShallow to avoid unneeded re-renders.
By understanding how each tool works under the hood and carefully structuring your state access patterns, you can keep your components snappy and your users happy.
Also, here’s the Github Link to the source code used in this blog.
I hope this blog helped you understand how to improve your application’s performance — and most importantly, that you learned something new. 😊
Happy Coding! < >👨🏻💻🎓⚛</>
Top comments (0)