Tech Bridge Log
Tech BridgeLog
🧭

Redux / Zustand / Jotai Implementation Comparison Guide

12 min read

A guide that compares the implementation patterns of Redux, Zustand, and Jotai in React apps by use case and organizes the perspectives for choosing between them.

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

npm-trends

jotai vs redux vs zustand | npm trends


1. Counter: Differences in the Smallest Setup

Store / Atom Implementations

Redux

ts
// 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

ts
import { create } from "zustand"

export const useCounterStore = create(set => ({
  count: 0,
  increment: () => set(s => ({ count: s.count + 1 })),
}))

Jotai

ts
import { atom } from "jotai"

export const countAtom = atom(0)
export const incrementAtom = atom(null, (_, set) =>
  set(countAtom, c => c + 1),
)

Using Them in Components

tsx
// 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)

ts
// 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)

ts
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)

ts
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

tsx
// 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

ts
// 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

ts
import { create } from "zustand"

export const useFormStore = create(set => ({
  name: "",
  email: "",
  setName: (name: string) => set({ name }),
  setEmail: (email: string) => set({ email }),
}))

Jotai

ts
import { atom } from "jotai"

export const nameAtom = atom("")
export const emailAtom = atom("")

Component Implementations

tsx
// 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

ts
// 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

ts
import { create } from "zustand"

export const useModalStore = create(set => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}))

Jotai

ts
import { atom } from "jotai"

export const modalAtom = atom(false)

Components

tsx
// 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)

ts
// 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)

ts
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)

ts
import { atom } from "jotai"

export const userAtom = atom({ name: "Alice" })
export const messageAtom = atom(get => `Hello from ${get(userAtom).name}`)

Rendering

tsx
// 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

ts
// 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,
})
ts
// 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

PerspectiveReduxZustandJotai
Learning curveHighLowMedium
Amount of codeLargeSmallSmall
PerformanceGoodExcellentExcellent
Extension approachArchitecture expansion via middlewareFeature expansion by composing store middlewareDependency and utility expansion via atoms
Referencing other stateSafe dependencies with selectorsReference with the get functionDeclare 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): useSelector narrows the subscription scope, but optimizing immutable updates may require extra work such as reselect.
  • 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

ItemReduxZustandJotai
Extension methodStrengthen the action flow with middlewareCompose store middleware functionsExtend dependencies with atoms and utilities
Main use casesSide-effect management, logging, monitoring, state governancePersistence, DevTools integration, subscription optimizationPersistence, async processing, derived logic
Design philosophyArchitectural expansion via the Flux patternLocalized expansion with a Hook mindsetDeclarative and functional dependency expansion
Focus of extensionControlling the architecture of the entire appFlexible expansion per storeExtending logic by combining atoms
  • Redux: Middleware makes it easy to control the action flow. Tools such as redux-thunk and redux-saga let you systematize async processing and monitoring.
  • Zustand: You can compose features like persist, devtools, and subscribeWithSelector. It can be introduced with a minimal Hook-like mindset, although design guidelines are left to the developer.
  • Jotai: Packages like jotai/utils and jotai/query add 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 the get argument. 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.

Related Articles