Introduction
When designing state management in React, the question of which library to choose has a major impact on the product development experience. Redux, the long-time standard, is robust but tends to accumulate boilerplate through action and reducer definitions. In recent years, lightweight options such as Zustand and Jotai have appeared, and they are increasingly adopted for small to medium projects.
This article compares the coding feel and underlying philosophy of Redux, Zustand, and Jotai across five representative use cases with concrete examples. To help you make the right choice for each purpose, the final section summarizes evaluation points.
Reference: npm trends

jotai vs redux vs zustand | npm trends
1. Counter: Differences in the Smallest Setup
Store / Atom Implementations
Redux
// actionTypes.ts
export const INCREMENT = "INCREMENT"
// actions.ts
export const increment = () => ({ type: INCREMENT })
// reducer.ts
import { INCREMENT } from "./actionTypes"
const initialState = { count: 0 }
export function counterReducer(state = initialState, action: any) {
switch (action.type) {
case INCREMENT:
return { count: state.count + 1 }
default:
return state
}
}
Zustand
import { create } from "zustand"
export const useCounterStore = create(set => ({
count: 0,
increment: () => set(s => ({ count: s.count + 1 })),
}))
Jotai
import { atom } from "jotai"
export const countAtom = atom(0)
export const incrementAtom = atom(null, (_, set) =>
set(countAtom, c => c + 1),
)
Using Them in Components
// Redux
const count = useSelector((s: any) => s.counter.count)
const dispatch = useDispatch()
;<button onClick={() => dispatch(increment())}>+1</button>
// Zustand
const { count, increment } = useCounterStore()
;<button onClick={increment}>+1</button>
// Jotai
const [count] = useAtom(countAtom)
const [, increment] = useAtom(incrementAtom)
;<button onClick={increment}>+1</button>
Redux separates actions and reducers explicitly, which makes structure easy to reason about but increases boilerplate. Zustand and Jotai finish in a single file and can be handled with the same mindset as React Hooks.
2. Async Data Fetching (Todo List)
Redux (redux-thunk)
// store.ts
import { createStore, applyMiddleware } from "redux"
import thunk from "redux-thunk"
import { todoReducer } from "./todoReducer"
export const store = createStore(todoReducer, applyMiddleware(thunk))
// actions.ts
export const FETCH_START = "FETCH_START"
export const FETCH_SUCCESS = "FETCH_SUCCESS"
export const FETCH_ERROR = "FETCH_ERROR"
export const fetchTodos = () => async (dispatch: any) => {
dispatch({ type: FETCH_START })
try {
const res = await fetch("/api/todos")
const data = await res.json()
dispatch({ type: FETCH_SUCCESS, payload: data })
} catch (e: any) {
dispatch({ type: FETCH_ERROR, error: e.message })
}
}
// reducer.ts
const initialState = { todos: [], loading: false, error: null }
export function todoReducer(state = initialState, action: any) {
switch (action.type) {
case FETCH_START:
return { ...state, loading: true }
case FETCH_SUCCESS:
return { todos: action.payload, loading: false }
case FETCH_ERROR:
return { ...state, loading: false, error: action.error }
default:
return state
}
}
Redux can insert middleware (here, redux-thunk) to manage side effects safely as part of the action flow. The stricter the team wants to control the flow, the more valuable this becomes.
Zustand (Defining an Async Function Directly)
import { create } from "zustand"
export const useTodoStore = create(set => ({
todos: [],
loading: false,
error: null,
fetchTodos: async () => {
set({ loading: true })
try {
const res = await fetch("/api/todos")
const data = await res.json()
set({ todos: data, loading: false })
} catch (e: any) {
set({ error: e.message, loading: false })
}
},
}))
Zustand lets you write async functions directly in the store. You can declare both state and side effects with the bare minimum, and the learning cost stays low.
Jotai (Side-Effect Atom)
import { atom } from "jotai"
export const todosAtom = atom<any[]>([])
export const loadingAtom = atom(false)
export const fetchTodosAtom = atom(null, async (_, set) => {
set(loadingAtom, true)
const res = await fetch("/api/todos")
const data = await res.json()
set(todosAtom, data)
set(loadingAtom, false)
})
Jotai declares side effects as atoms and places them inside the dependency graph. It expresses data fetching and state updates declaratively, and it automatically optimizes re-evaluation.
Comparison of Component Usage
// Redux
const { todos, loading } = useSelector((s: any) => s.todos)
const dispatch = useDispatch()
useEffect(() => {
dispatch(fetchTodos())
}, [dispatch])
// Zustand
const { todos, loading, fetchTodos } = useTodoStore()
useEffect(() => {
fetchTodos()
}, [fetchTodos])
// Jotai
const [todos] = useAtom(todosAtom)
const [loading] = useAtom(loadingAtom)
const [, fetchTodos] = useAtom(fetchTodosAtom)
useEffect(() => {
fetchTodos()
}, [fetchTodos])
3. Form Input Management
State Management Implementations
Redux
// actions.ts
export const SET_NAME = "SET_NAME"
export const SET_EMAIL = "SET_EMAIL"
export const setName = (name: string) => ({ type: SET_NAME, payload: name })
export const setEmail = (email: string) => ({
type: SET_EMAIL,
payload: email,
})
// reducer.ts
const initialState = { name: "", email: "" }
export function formReducer(state = initialState, action: any) {
switch (action.type) {
case SET_NAME:
return { ...state, name: action.payload }
case SET_EMAIL:
return { ...state, email: action.payload }
default:
return state
}
}
Zustand
import { create } from "zustand"
export const useFormStore = create(set => ({
name: "",
email: "",
setName: (name: string) => set({ name }),
setEmail: (email: string) => set({ email }),
}))
Jotai
import { atom } from "jotai"
export const nameAtom = atom("")
export const emailAtom = atom("")
Component Implementations
// Redux
const { name, email } = useSelector((s: any) => s.form)
const dispatch = useDispatch()
;<input value={name} onChange={e => dispatch(setName(e.target.value))} />
;<input value={email} onChange={e => dispatch(setEmail(e.target.value))} />
// Zustand
const { name, email, setName, setEmail } = useFormStore()
;<input value={name} onChange={e => setName(e.target.value)} />
;<input value={email} onChange={e => setEmail(e.target.value)} />
// Jotai
const [name, setName] = useAtom(nameAtom)
const [email, setEmail] = useAtom(emailAtom)
;<input value={name} onChange={e => setName(e.target.value)} />
;<input value={email} onChange={e => setEmail(e.target.value)} />
Looking at the amount of binding code, Redux is the most structural, while Zustand and Jotai keep the declarations to a minimum.
4. Modal Toggle
State Management
Redux
// actions.ts
export const OPEN_MODAL = "OPEN_MODAL"
export const CLOSE_MODAL = "CLOSE_MODAL"
export const openModal = () => ({ type: OPEN_MODAL })
export const closeModal = () => ({ type: CLOSE_MODAL })
// reducer.ts
const initialState = { isOpen: false }
export function modalReducer(state = initialState, action: any) {
switch (action.type) {
case OPEN_MODAL:
return { isOpen: true }
case CLOSE_MODAL:
return { isOpen: false }
default:
return state
}
}
Zustand
import { create } from "zustand"
export const useModalStore = create(set => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}))
Jotai
import { atom } from "jotai"
export const modalAtom = atom(false)
Components
// Redux
const isOpen = useSelector((s: any) => s.modal.isOpen)
const dispatch = useDispatch()
;<button onClick={() => dispatch(openModal())}>Open</button>
{isOpen && <button onClick={() => dispatch(closeModal())}>Close</button>}
// Zustand
const { isOpen, open, close } = useModalStore()
;<button onClick={open}>Open</button>
{isOpen && <button onClick={close}>Close</button>}
// Jotai
const [isOpen, setOpen] = useAtom(modalAtom)
;<button onClick={() => setOpen(true)}>Open</button>
{isOpen && <button onClick={() => setOpen(false)}>Close</button>}
Zustand and Jotai provide intuitive button bindings, whereas Redux trades explicit action definitions for testability and extensibility.
5. Dependencies Between States
Cases where multiple pieces of state affect one another reveal the philosophy of each library most clearly.
Redux (Selector)
// userReducer.ts
const initialUser = { name: "Alice" }
export function userReducer(state = initialUser, _action: any) {
return state
}
// profileReducer.ts
const initialProfile = { message: "" }
export function profileReducer(state = initialProfile, _action: any) {
return state
}
// selectors.ts
import { createSelector } from "reselect"
export const selectUser = (state: any) => state.user
export const selectUserName = createSelector([selectUser], u => u.name)
export const selectUserMessage = createSelector([selectUserName], name => {
return `Hello from ${name}`
})
Zustand (Cross-Store Reference)
import { create } from "zustand"
export const useUserStore = create(() => ({
name: "Alice",
}))
export const useProfileStore = create(set => ({
message: "",
setMessageFromUser: () => {
const user = useUserStore.getState().name
set({ message: `Hello from ${user}` })
},
}))
Jotai (Dependent Atom)
import { atom } from "jotai"
export const userAtom = atom({ name: "Alice" })
export const messageAtom = atom(get => `Hello from ${get(userAtom).name}`)
Rendering
// Redux
const greeting = useSelector(selectUserMessage)
;<p>{greeting}</p> // "Hello from Alice"
// Zustand
const { message, setMessageFromUser } = useProfileStore()
;<button onClick={setMessageFromUser}>Set message</button>
;<p>{message}</p> // "Hello from Alice"
// Jotai
const [message] = useAtom(messageAtom)
;<p>{message}</p> // "Hello from Alice"
Redux keeps dependencies explicit through selectors, Zustand references other stores via getState(), and Jotai handles dependencies declaratively.
6. Consolidating State with Redux combineReducers
// rootReducer.ts
import { combineReducers } from "redux"
import { counterReducer } from "./counterReducer"
import { todoReducer } from "./todoReducer"
import { formReducer } from "./formReducer"
import { modalReducer } from "./modalReducer"
export const rootReducer = combineReducers({
counter: counterReducer,
todos: todoReducer,
form: formReducer,
modal: modalReducer,
})
// store.ts
import { createStore } from "redux"
import { rootReducer } from "./rootReducer"
export const store = createStore(rootReducer)
Redux offers a structure for consolidating state across the entire app, making the responsibility of each slice clear. Zustand and Jotai keep separate stores or atoms loosely coupled, which makes fine-grained adjustments easy, but leaves the full picture up to the developer to manage.
7. Pros and Cons
| Perspective | Redux | Zustand | Jotai |
|---|---|---|---|
| Learning curve | High | Low | Medium |
| Amount of code | Large | Small | Small |
| Performance | Good | Excellent | Excellent |
| Extension approach | Architecture expansion via middleware | Feature expansion by composing store middleware | Dependency and utility expansion via atoms |
| Referencing other state | Safe dependencies with selectors | Reference with the get function | Declare dependencies declaratively |
Learning Curve
- Redux: Beyond actions, reducers, and stores, you need additional learning for side-effect handling with tools like redux-thunk or redux-saga. Redux Toolkit helps reduce ceremony, but understanding the design philosophy still takes time.
- Zustand: You simply define state and logic with
create, so it feels intuitive. Because the API resembles React Hooks, even newcomers can adopt it quickly. - Jotai: It feels close to React state, yet it requires an understanding of atom dependencies and derived atoms. It shines when a declarative mindset fits your project.
Amount of Code
- Redux: Every state change requires building an Action → Dispatch → Reducer flow, so even a simple counter often ends up split across multiple files.
- Zustand: State and update logic live together inside a single store and are called as Hooks. With no need for action type definitions, you often write less than half the code required for Redux.
- Jotai: You declare an atom in a single line and encapsulate updates in atom functions. Developers who prefer functional thinking over structural patterns can be very productive.
Performance
- Redux (good):
useSelectornarrows the subscription scope, but optimizing immutable updates may require extra work such asreselect. - Zustand (excellent): It performs shallow comparisons internally, and the store operates outside the React render cycle to minimize re-renders.
- Jotai (excellent): Atoms have explicit dependencies, so only the targets of an update are re-evaluated. A declarative dependency graph directly translates into performance.
Extensibility
| Item | Redux | Zustand | Jotai |
|---|---|---|---|
| Extension method | Strengthen the action flow with middleware | Compose store middleware functions | Extend dependencies with atoms and utilities |
| Main use cases | Side-effect management, logging, monitoring, state governance | Persistence, DevTools integration, subscription optimization | Persistence, async processing, derived logic |
| Design philosophy | Architectural expansion via the Flux pattern | Localized expansion with a Hook mindset | Declarative and functional dependency expansion |
| Focus of extension | Controlling the architecture of the entire app | Flexible expansion per store | Extending logic by combining atoms |
- Redux: Middleware makes it easy to control the action flow. Tools such as
redux-thunkandredux-sagalet you systematize async processing and monitoring. - Zustand: You can compose features like
persist,devtools, andsubscribeWithSelector. It can be introduced with a minimal Hook-like mindset, although design guidelines are left to the developer. - Jotai: Packages like
jotai/utilsandjotai/queryadd persistence and async processing. The(get, set)pattern lets you build dependency graphs flexibly while keeping derived logic declarative.
Designing Cross-State References
- Redux: Selectors make dependencies explicit and easy to memoize. You can aggregate multiple states while avoiding tight coupling between reducers.
- Zustand: You reference other stores or slices freely with
useStore.getState()or thegetargument. The freedom is high, but developers must track the dependencies themselves. - Jotai: Declaring dependencies in the
(get) => ...form automatically manages re-evaluation timing. It excels at keeping the dependency graph tidy.
Summary and Selection Tips
Redux, Zustand, and Jotai can all handle state management in React apps, but they differ clearly in design philosophy, amount of code, and direction of extensibility. As the snippets show, even for the same counter feature, Redux spans about three files, Zustand fits in one, and Jotai can be built in just a few lines.
- If you want to manage structure strictly and keep the entire flow visible -> Redux
- If you prioritize speed and want to ship features with minimal code -> Zustand
- If you want to split state finely and minimize re-rendering -> Jotai
Match these traits against your project size, team experience, and required extensibility when choosing. Use the snippets in this article as a starting point and adapt them to your actual requirements.