· zustand / jotai / react

Zustand vs Jotai — chọn kiểu state management nào?

Zustand cho global state update từ bất kỳ đâu; Jotai cho derived state cần tối ưu re-render. Benchmark, so sánh API, workload cụ thể để chọn đúng.

Bởi

2.718 từ · 14 phút đọc

Dùng Zustand cho global state của app và bất cứ thứ gì cần update từ ngoài React. Dùng Jotai khi bạn có derived state phức tạp và muốn React tự tối ưu re-render mà không cần selector discipline. Đó là kết luận — phần còn lại giải thích khi nào kết luận này đảo ngược và tại sao hai thư viện hoàn toàn có thể cùng tồn tại trong một codebase.

Bài này dành cho ai

React engineer đang chọn state manager tối giản sau khi useState + Context không còn đủ dùng, hoặc những người đã dùng Redux và muốn bỏ 90% boilerplate. Nếu bạn đang dùng Zustand hoặc Jotai và cân nhắc migrate, phần migration pathsbridge section áp dụng trực tiếp.

Bài này không dành cho team đang build app một form đơn giản. Nếu mọi thứ vừa gọn trong một useReducer, bạn không cần cả hai thư viện này.

Chúng tôi đã so sánh gì

Zustand v5.0.13 (phát hành 2026-05-05) so với Jotai v2.20.0 (phát hành 2026-05-06). Cả hai đều được duy trì bởi pmndrs (Poimandres) — cùng một team, hai công cụ khác nhau. Số liệu lấy từ npm download API, GitHub API, và các benchmark bên thứ ba từ 2025–2026.

Zustand và Jotai: nhìn chung

Zustand v5.0.13Jotai v2.20.0
Lượt download / tuần37 triệu3,7 triệu
GitHub stars58.06521.168
Bundle (gzip)~3 KB~4 KB
Mental modelExternal store, top-downAtomic, bottom-up
Re-renderPhụ thuộc vào selectorTự động theo từng atom
AsyncFetch + set thủ côngNative Suspense
DevToolsBuilt-in middlewarePackage riêng jotai-devtools
TypeScriptGeneric khai báo tường minhInfer từ giá trị khởi tạo
Truy cập ngoài ReactuseBearStore.getState()createStore() + vanilla API
Phù hợp nhấtGlobal state, event handlers, WebSocketsDerived state, forms, Suspense-heavy UI

Hai mental model thực sự khác nhau

Zustand và Jotai nằm ở hai đầu đối lập của cùng một đánh đổi. Chính tác giả của Jotai, @dai-shi, đã nói thẳng: “Jotai gần với Recoil. Zustand gần với Redux.”

Zustand giữ state trong một external store tồn tại ngoài React tree. Bạn kết nối component với store qua hook và selector. Store là một object duy nhất — bạn định nghĩa hình dạng của nó, còn selector sẽ cắt ra phần mà mỗi component cần.

Jotai đặt state bên trong React tree dưới dạng các atom độc lập. Component subscribe vào đúng những atom nó cần đọc. Derived atom — atom tính toán từ atom khác — chỉ re-evaluate khi atom upstream thay đổi, và chỉ component subscribe vào derived atom đó mới re-render.

Không cái nào khách quan tốt hơn. Chúng tối ưu cho hai dạng lỗi khác nhau: Zustand tối ưu cho sự tiện lợi khi cần tác động lên state từ bất kỳ đâu; Jotai tối ưu cho sự chính xác khi state có nhiều lớp derived.

Hình dạng API

Counter — so sánh song song

Zustand:

// store.ts
import { create } from 'zustand'

interface CounterStore {
  count: number
  inc: () => void
}

const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}))

// Component.tsx
const count = useCounterStore((s) => s.count)
const inc = useCounterStore((s) => s.inc)

Jotai:

// atoms.ts
import { atom } from 'jotai'

const countAtom = atom(0)

// Component.tsx
import { useAtom } from 'jotai'

const [count, setCount] = useAtom(countAtom)
// setCount((c) => c + 1)

Code Jotai ngắn hơn. Nhưng Zustand thể hiện rõ lợi thế khi co-locate action trong store: khi store có 10 field và 15 action, gom tất cả vào một create() block với kiểu tường minh sẽ dễ navigate hơn nhiều so với 15 file atom rải rác.

Async data fetching — so sánh song parallel

Zustand:

const useUserStore = create<{ user: User | null; load: () => void }>((set) => ({
  user: null,
  load: async () => {
    const res = await fetch('/api/user')
    set({ user: await res.json() })
  },
}))

// Phải gọi load() thủ công; không có Suspense mặc định
const { user, load } = useUserStore()
useEffect(() => { load() }, [])

Jotai:

const userAtom = atom(async (_, { signal }) => {
  const res = await fetch('/api/user', { signal })
  return res.json() as Promise<User>
})

// Bọc component trong <Suspense> — không cần quản lý loading state
const [user] = useAtom(userAtom)

Async atom của Jotai tích hợp với React Suspense ngay từ đầu. Bạn viết fetch, bọc component trong <Suspense>, xong. Không cần flag loading, không cần useEffect để khởi động request. Cách của Zustand rõ ràng hơn — đó là ưu điểm nếu bạn cần kiểm soát thời điểm fetch bắt đầu.

Hành vi re-render

Đây là nơi sự khác biệt kiến trúc hiện rõ nhất. Model subscription theo từng atom của Jotai đảm bảo component subscribe vào usernameAtom không bao giờ re-render khi notificationCountAtom thay đổi. Điều đó được đảm bảo bởi cấu trúc.

Trong Zustand, selector quyết định tần suất re-render. Viết đúng thì bạn đạt hiệu quả ngang Jotai. Viết sai và mọi subscriber của toàn bộ store sẽ re-render mỗi lần có thay đổi.

Từ benchmark năm 2025 của Rohit Imandi — test component subscribe vào các slice state khác nhau, với một slice thay đổi:

Cách tiếp cậnTổng renderRender không cần thiết
Zustand (selector rộng)93 (33% lãng phí)
Zustand (selector chính xác)60
Jotai60 (tự động)

Trường hợp selector rộng thêm +7.506 ms overhead tính toán trong kịch bản xử lý nặng. Jotai đạt kết quả tương đương Zustand được tinh chỉnh tối ưu — mà không cần làm gì thêm.

Một stress test năm 2026 trên dev.to với 1.000 component cho góc nhìn khác:

Chỉ sốZustandJotai
Thời gian render khi update đơn12ms14ms
Mức dùng bộ nhớ2,1 MB1,8 MB

Zustand thắng về throughput thô ~2ms. Jotai dùng ít hơn ~14% bộ nhớ. Cả hai chênh lệch gần như không đáng kể trong thực tế — chúng là nhiễu so với network latency hoặc một render tree chậm ở nơi khác trong app.

Tóm lại: Jotai an toàn hơn theo mặc định; Zustand đạt được điều tương tự nếu team bạn duy trì selector discipline. Nếu team bạn có selector discipline tốt, Zustand nhỉnh hơn đôi chút về throughput. Nếu từng có ai trong team ship useStore() không có selector trên một store update liên tục — Jotai loại bỏ cái bẫy đó.

Một công cụ trong Zustand đáng biết: useShallow (v5), bắt buộc khi selector trả về object hoặc array.

import { useShallow } from 'zustand/react/shallow'

// Không có useShallow: object reference mới mỗi lần render
const { name, age } = useStore(useShallow((s) => ({ name: s.name, age: s.age })))

Quên useShallow trong v5 khiến component subscribe vào object-shaped selector re-render mỗi lần store update. Jotai không có lớp lỗi này vì bản thân atom là stable reference.

DevTools và hệ sinh thái

DevTools

Zustand đi kèm middleware devtools ngay từ đầu. Middleware này tích hợp với Redux DevTools Extension và hỗ trợ time-travel, action logging, và state snapshot.

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const useStore = create(devtools((set) => ({
  count: 0,
  inc: () => set({ count: 1 }, false, 'inc'),  // 'inc' đặt tên action trong devtools
})))

Jotai cần cài thêm package jotai-devtoolsnpm install jotai-devtools. Package này cung cấp một UI component nhúng trực tiếp vào app (không phải browser extension), cùng năm debugging hook bao gồm useAtomDevtools để kết nối atom riêng lẻ với Redux DevTools Extension, và useGotoAtomsSnapshot cho time-travel.

Nếu team bạn đã dùng Redux DevTools Extension, tích hợp built-in của Zustand là lựa chọn ít setup nhất. Jotai-devtools mạnh hơn cho việc debug ở cấp độ atom nhưng cần cấu hình nhiều hơn.

So sánh hệ sinh thái

Tính năngZustandJotai
Redux DevToolsBuilt-in middlewareQua hook useAtomDevtools
PersistenceMiddleware persistatomWithStorage
ImmerMiddleware immerjotai-immer
TanStack Query bridgeDùng riêngatomWithQuery (jotai-tanstack-query)
XStateDùng riêngjotai-xstate
tRPCDùng riêngjotai-trpc
Optics / lensesKhông cójotai-optics
SSR / Next.jsStore factory patternProvider theo mỗi request
React NativeCó (với AsyncStorage)

Jotai có hệ sinh thái extension rộng hơn. Zustand có middleware first-class cho những trường hợp phổ biến nhất (devtools, persist, immer) và dựa vào phần còn lại của hệ sinh thái React cho mọi thứ khác. Không cách nào sai — câu hỏi là bạn muốn tích hợp được bake sẵn vào thư viện hay xử lý bên ngoài. Cả hai chỉ quản lý client state; cho server state (caching, revalidation API data), TanStack Query vs SWR là bài so sánh tiếp theo nên đọc.

TypeScript DX

Zustand v5 yêu cầu khai báo generic tường minh khi tạo store.

// Generic tường minh — dễ quên, cần thiết để đảm bảo type safety
const useStore = create<{ count: number; inc: () => void }>()((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}))

// Kiểu của selector được infer từ kiểu của store
const count: number = useStore((s) => s.count)

Jotai infer kiểu từ giá trị khởi tạo.

const countAtom = atom(0)           // Atom<number> — inferred
const userAtom = atom<User | null>(null)  // khai báo tường minh khi cần

// Derived atom được infer đầy đủ
const doubledAtom = atom((get) => get(countAtom) * 2)  // Atom<number>

// Async atom resolve kiểu Promise
const dataAtom = atom(async () => fetch('/api').then((r) => r.json() as Data))  // Atom<Data>

Inference của Jotai gọn hơn với atom đơn giản. Cả hai thư viện đều type-safe đầy đủ tại điểm sử dụng. Điểm đau TypeScript chính trong Zustand là stack middleware — devtools(persist(immer(...))) tạo ra kiểu generic lồng nhau sâu khiến error message khó đọc. Composability qua derived atom của Jotai tránh được pattern này.

Con đường migration

Từ Redux sang Zustand

Hai mental model gần nhau: single global store, cập nhật theo action, hỗ trợ DevTools. Thay đổi chính là bỏ dispatch, action creator, và reducer — bạn gọi set() trực tiếp. Migrate từng slice một, thay thế mỗi Redux slice bằng một Zustand store. Learning curve tính bằng giờ, không phải ngày. Để so sánh chi tiết hơn với Redux Toolkit, Zustand vs Redux Toolkit đi sâu vào từng điểm khác biệt.

Từ Redux sang Jotai

Đây là sự thay đổi paradigm lớn hơn. Store tập trung trở thành các atom phân tán; Redux selector trở thành derived atom. Với codebase lớn, tính từ 1–3 ngày để retrain team. Lợi ích đổi lại là re-render tối ưu tự động và async pattern đơn giản hơn. Bắt đầu với feature mới bằng Jotai và migrate các slice cũ khi bạn đụng vào chúng.

Từ useState + Context sang cả hai

Zustand: thay useContext(StateContext) bằng useStore(selector). Bỏ Provider. Cả hai component cùng truy cập vào một external store instance.

Jotai: thay useState bằng useAtom(atom). Gần như không thay đổi cấu trúc component. Opt-in vào Provider cho các subtree độc lập.

Giữa Zustand và Jotai

Package jotai-zustand cung cấp atomWithStore(store) — binding hai chiều giữa Zustand store và Jotai atom.

import { createStore } from 'zustand/vanilla'
import { atomWithStore } from 'jotai-zustand'
import { useAtom } from 'jotai'

const zustandStore = createStore(() => ({ count: 0 }))
const storeAtom = atomWithStore(zustandStore)

const Counter = () => {
  const [state, setState] = useAtom(storeAtom)
  return (
    <button onClick={() => setState((prev) => ({ count: prev.count + 1 }))}>
      {state.count}
    </button>
  )
}

Update qua setState (Jotai) sẽ sync ngược về Zustand store, và ngược lại. Điều này cho phép migrate từng phần thay vì phải làm hết một lần.

Breaking change v5 / v2

Nếu bạn đang upgrade, cả hai thư viện đều có major breaking version.

Zustand v5 (tháng 10 năm 2024):

  • Bỏ default export — dùng import { create } from 'zustand'
  • Selector trả về object hoặc array phải dùng useShallow để tránh infinite re-render
  • setState(state, true) yêu cầu full state object khi replace: true
  • React 18 tối thiểu; TypeScript 4.5 tối thiểu
  • Middleware persist không còn auto-initialize; cần gọi setState tường minh sau khi tạo store

Hướng dẫn đầy đủ: zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5

Jotai v2 (tháng 3 năm 2023, hiện tại v2.20.0):

  • Async atom yêu cầu await tường minh — atom(async (get) => await get(asyncAtom))
  • Kiểu WritableAtom thay đổi: WritableAtom<Value, Args extends unknown[], Result>
  • Provider.initialValues bị xóa; dùng useHydrateAtoms
  • Provider.scope bị xóa; dùng custom React Context
  • jotai/babel chuyển sang package riêng jotai-babel (v2.18.0, tháng 2 năm 2026)

Hướng dẫn đầy đủ: jotai.org/docs/guides/migrating-to-v2-api

Khi nào dùng cả hai

Bridge jotai-zustand không chỉ là công cụ migration. Hai pattern mà cả hai thực sự cùng tồn tại:

Tách global/local: Zustand cho state toàn app (auth session, feature flags, cart), Jotai cho derived state cục bộ phức tạp (tổ hợp filter, form field phụ thuộc lẫn nhau, computed dashboard view). Mỗi thư viện xử lý đúng domain mà nó mạnh hơn.

Migration từng bước: Feature mới ship bằng Jotai; Zustand store cũ giữ nguyên cho đến khi bạn sẵn sàng. Bridge giữ cả hai đồng bộ mà không cần flag day.

Kết luận

Chọn Zustand nếu:

  • State chủ yếu là global và phẳng (auth, user prefs, cart, feature flags)
  • Bạn cần update state từ ngoài React — event listener, WebSocket handler, background job
  • Team bạn quen với Redux và muốn retrain ít nhất có thể
  • Bạn muốn Redux DevTools tích hợp sẵn không cần setup thêm
  • Bạn cần ship trong tuần này

Chọn Jotai nếu:

  • Bạn có derived state phức tạp với nhiều atom tính toán từ nhau
  • UI có 30+ form field hoặc tổ hợp filter phụ thuộc nhau và tần suất re-render quan trọng
  • Bạn đang build feature dựa nhiều vào React Suspense cho async data
  • Bạn muốn re-render tối ưu tự động mà không cần selector discipline
  • Bạn đang thay thế useState + Context dần dần và muốn thay đổi cấu trúc ít nhất có thể

Cả hai đều ~3–4 KB gzip, cả hai đều được pmndrs maintain với gần như không có open issue, và cả hai ổn định từ khi ra v5/v2. Không có lựa chọn sai cho một CRUD dashboard thông thường. Sự khác biệt hiện ra khi chịu tải trên UI nặng derived state, hoặc khi bạn cần tác động lên state từ ngoài React tree.

Lưu ý

  • Số liệu stress test với 1.000 component (12ms vs 14ms) đến từ một tác giả benchmark trên một máy duy nhất. Xem như chỉ dẫn, không phải con số tuyệt đối.
  • atomWithStorage của Jotai có thể gây hydration mismatch trong Next.js — server render giá trị khởi tạo, client đọc từ localStorage. Mặc định đã được bảo vệ (getOnInit: false), nhưng hãy kiểm tra setup nếu bạn đang dùng SSR với persisted atom.
  • Không thư viện nào có affiliate program; bài này không có paid placement nào.

Tài liệu tham khảo