How to Migrate from Redux to Zustand or Jotai
How do I migrate from Redux to Zustand or Jotai?
TL;DR
- Bottom line: Replace Redux slices one at a time with Zustand stores (single-store, action-based) or Jotai atoms (atomic, bottom-up) — both coexist with Redux during migration, require no Provider wrapper (Zustand) or minimal setup (Jotai), and cut boilerplate by 60-80%.
- Key tool/command:
const useStore = create((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })) })) - Watch out for: Migrating the entire Redux store at once — migrate slice-by-slice, keeping both libraries running side-by-side until each slice is verified.
- Works with: Zustand 5.0.14 (React 18+, TS 4.5+), Jotai 2.20.0 (React 17+), Redux Toolkit 2.x. Compatible with TypeScript 5+, Next.js 14+/15, Vite, Webpack.
Constraints
- Zustand v5 requires React 18+ — if your app uses React 17 or earlier, use Zustand 4.x or choose Jotai instead. [src6]
- Never migrate all Redux slices at once — use incremental slice-by-slice migration to prevent regressions and stalled feature development. [src4]
- Zustand middleware must be ordered correctly:
create(devtools(persist(immer(...))))— devtools outermost, immer innermost. Wrong order breaks DevTools. [src6] - Never let Zustand and Redux manage the same state simultaneously — keep clear ownership boundaries for each slice during coexistence. [src5]
- Zustand’s
persistmiddleware uses localStorage by default — configurepartializeto exclude sensitive data (tokens, secrets) or usesessionStorage. [src3] - Jotai atoms must be defined at module scope — defining atoms inside React components recreates them every render, silently losing all state. [src2]
Quick Reference
| Redux Pattern | Zustand Equivalent | Jotai Equivalent |
|---|---|---|
configureStore({ reducer }) | create((set) => ({ ... })) | atom(initialValue) — no store needed |
createSlice({ reducers }) | Actions defined inline: inc: () => set(...) | Write atoms: atom(null, (get, set, arg) => ...) |
useSelector(s => s.slice.value) | useStore(s => s.value) | useAtomValue(myAtom) |
useDispatch() + dispatch(action()) | useStore(s => s.actionName)() — call directly | useSetAtom(writeAtom)(arg) |
createAsyncThunk | Async function inside store: set({ loading: true }); await ... | Async write atom: atom(null, async (get, set) => ...) |
createSelector (reselect) | Selector function in hook: useStore(s => s.users.filter(...)) | Derived atom: atom(get => get(usersAtom).filter(...)) |
<Provider store={store}> | No provider needed | <Provider> optional (for scoping) |
redux-persist | Built-in: persist((set) => (...), { name: 'key' }) | atomWithStorage('key', defaultVal) |
| Redux DevTools (built-in) | devtools((set) => (...)) middleware | useAtomsDevtools('label') |
| Immer (built-in in RTK) | immer((set) => (...)) middleware | Not needed — atoms are granular |
combineReducers / slices | Slices pattern or multiple stores | Compose atoms — each atom is independent |
| Middleware chain | create(devtools(persist(immer(...)))) | Atom utilities: atomWithStorage, atomWithReducer |
extraReducers (action listeners) | subscribe(listener) on store | onMount atom effect |
store.getState() (outside React) | useStore.getState() | store.get(myAtom) (vanilla store) |
Decision Tree
START
├── Do you need a single centralized store (like Redux)?
│ ├── YES → Choose Zustand (closest mental model to Redux)
│ └── NO ↓
├── Do you have many independent pieces of state (form fields, toggles, filters)?
│ ├── YES → Choose Jotai (atomic model, fine-grained re-renders)
│ └── NO ↓
├── Do you rely heavily on Redux middleware (thunks, sagas, RTK Query)?
│ ├── YES → Keep RTK Query for server cache, migrate client state to Zustand
│ └── NO ↓
├── Is your Redux store <5 slices with simple CRUD?
│ ├── YES → Zustand (simplest migration path, 1:1 mapping)
│ └── NO ↓
├── Do you need Suspense integration for async state?
│ ├── YES → Jotai (native Suspense support via async atoms)
│ └── NO ↓
├── Is your project using React 17 or earlier?
│ ├── YES → Jotai 2.x (supports React 17+) or Zustand 4.x
│ └── NO ↓
└── DEFAULT → Zustand for global/shared state, Jotai for component-local atomic state
Decision Logic
Agent-facing if/then rules for recommending a migration path. Apply top-down; the first matching rule wins.
If the project is on React 17 or earlier
--> Do NOT install Zustand v5 (it dropped React <18 in v5.0). Use Zustand 4.x (npm i zustand@^4) or choose Jotai 2.x, which still supports React 17+. Plan a React 18 upgrade before adopting Zustand 5.0.14. [src1, src6]
If the Redux store is small (<5 slices, simple CRUD, no sagas)
--> Migrate to Zustand — it has the closest mental model to Redux and gives near 1:1 slice-to-store mapping. Expect a 1–3 week migration for a mid-sized app; “less code” is the single most-cited reason teams switch in 2026. [src4, src8]
If the app still ships features daily and cannot freeze
--> Migrate slice-by-slice with both libraries running side-by-side (incremental coexistence). Keep the Redux <Provider> until the last slice is gone. Never attempt a big-bang rewrite. [src4, src5]
If most of the “Redux state” is actually server/API cache
--> Do NOT move it to Zustand or Jotai. Migrate that layer to TanStack Query (React Query) for caching, invalidation, and polling; keep only true client/UI state in Zustand or Jotai. [src3]
If the state is many small independent values (form fields, toggles, filters) needing fine-grained re-renders
--> Choose Jotai (2.20.0) — atomic bottom-up model with native Suspense support. Define every atom at module scope. Use jotai-family (not the deprecated jotai/utils atomFamily). [src2, src7]
If you adopt Zustand v5 and a selector returns a new object/array
--> Wrap the selector in useShallow (from zustand/react/shallow) or use createWithEqualityFn with shallow — v5 compares with Object.is by default, so a fresh reference causes an infinite re-render loop. The plain create() no longer accepts an equality-function argument. [src6]
If you persist state with Zustand’s persist middleware
--> Pin Zustand >= 5.0.14. v5 stopped storing initial state at store-creation time and shipped persist race-condition and rehydration fixes across 5.0.10–5.0.12. Order middleware create(devtools(persist(immer(...)))) and partialize out any tokens/secrets. [src6]
Step-by-Step Guide
1. Audit your Redux usage
Map every Redux slice, thunk, selector, and middleware. Identify which slices are “client state” (UI, forms, auth) vs “server cache” (API data). Server cache is better handled by TanStack Query. [src4]
# Count Redux usage patterns
grep -rn "createSlice\|createAsyncThunk\|useSelector\|useDispatch" --include='*.ts' --include='*.tsx' | wc -l
grep -rn "configureStore\|combineReducers" --include='*.ts' --include='*.tsx'
grep -rn "redux-persist\|redux-saga\|redux-thunk" package.json
Verify: Produce a list of all slices with their state shape, action count, and whether they manage client state or server cache.
2. Install Zustand or Jotai alongside Redux
Both libraries coexist with Redux — no conflicts. Install without removing Redux. [src1, src3]
# For Zustand:
npm install zustand
# Optional middleware:
npm install immer
# For Jotai:
npm install jotai
Verify: import { create } from 'zustand' or import { atom } from 'jotai' compiles without errors.
3. Migrate one Redux slice to Zustand
Pick the simplest slice first. Convert reducers to inline actions on a Zustand store. Remove the slice from combineReducers after migration. [src4, src5]
// BEFORE: Redux Toolkit slice
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0, history: [] as number[] },
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
incrementByAmount: (state, action) => {
state.value += action.payload;
state.history.push(state.value);
},
},
});
// AFTER: Zustand store
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface CounterState {
value: number;
history: number[];
increment: () => void;
decrement: () => void;
incrementByAmount: (amount: number) => void;
}
const useCounterStore = create<CounterState>()(
devtools((set) => ({
value: 0,
history: [],
increment: () => set((s) => ({ value: s.value + 1 })),
decrement: () => set((s) => ({ value: s.value - 1 })),
incrementByAmount: (amount) =>
set((s) => ({
value: s.value + amount,
history: [...s.history, s.value + amount],
})),
}), { name: 'CounterStore' })
);
Verify: Replace useSelector and useDispatch in one component with the Zustand hook. Confirm identical behavior.
4. Migrate one Redux slice to Jotai (alternative path)
If choosing Jotai, decompose the Redux slice into individual atoms. Each piece of state becomes its own atom. [src2, src7]
import { atom } from 'jotai';
// Primitive atoms (replace Redux state fields)
export const countAtom = atom(0);
export const historyAtom = atom<number[]>([]);
// Write atoms (replace Redux actions)
export const incrementAtom = atom(null, (get, set) => {
set(countAtom, get(countAtom) + 1);
});
export const incrementByAmountAtom = atom(null, (get, set, amount: number) => {
const newValue = get(countAtom) + amount;
set(countAtom, newValue);
set(historyAtom, [...get(historyAtom), newValue]);
});
// Derived atom (replace Redux selector)
export const countSummaryAtom = atom((get) => ({
current: get(countAtom),
total: get(historyAtom).length,
}));
Verify: Replace useSelector with useAtomValue and useDispatch with useSetAtom. Confirm identical behavior.
5. Migrate async operations (thunks)
Replace createAsyncThunk with async functions inside Zustand stores or async write atoms in Jotai. [src3, src4]
// AFTER: Zustand async action
const useUserStore = create<UserState>()(
devtools((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const users = await res.json();
set({ users, loading: false });
} catch (err) {
set({ error: (err as Error).message, loading: false });
}
},
}), { name: 'UserStore' })
);
Verify: Network requests and loading/error states behave identically to the Redux version.
6. Replace Redux persistence
Swap redux-persist with built-in persistence middleware. [src3, src6]
// Zustand persist
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useAuthStore = create(
persist(
(set) => ({
token: null,
user: null,
login: async (credentials) => { /* ... */ },
logout: () => set({ token: null, user: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token, user: state.user }),
}
)
);
// Jotai atomWithStorage
import { atomWithStorage } from 'jotai/utils';
const tokenAtom = atomWithStorage<string | null>('auth-token', null);
Verify: Refresh the page — persisted state rehydrates correctly from localStorage.
7. Handle SSR (Next.js) store isolation
Zustand stores are singletons by default. For SSR with Next.js, use createStore + React context pattern to prevent state leaking between requests. Zustand v5 also offers experimental unstable_ssrSafe middleware. [src6, src8]
// SSR-safe Zustand store for Next.js App Router
import { createStore } from 'zustand';
import { createContext, useContext, useRef } from 'react';
const createAppStore = () =>
createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
type AppStore = ReturnType<typeof createAppStore>;
const AppStoreContext = createContext<AppStore | null>(null);
export function AppStoreProvider({ children }: { children: React.ReactNode }) {
const storeRef = useRef<AppStore>(null);
if (!storeRef.current) storeRef.current = createAppStore();
return (
<AppStoreContext.Provider value={storeRef.current}>
{children}
</AppStoreContext.Provider>
);
}
Verify: Run next build && next start, open two browser tabs — state should be isolated per request.
8. Remove Redux and clean up
After all slices are migrated and tested, uninstall Redux and its dependencies. [src4]
npm uninstall @reduxjs/toolkit react-redux redux-persist redux-thunk redux-saga
# Verify no Redux references remain
grep -rn "redux\|createSlice\|useSelector\|useDispatch\|configureStore" --include='*.ts' --include='*.tsx'
Verify: grep -rn '@reduxjs\|react-redux' package.json returns zero results. App runs without Redux loaded.
Code Examples
TypeScript/React: Full Redux-to-Zustand migration (Todo app)
Full script: typescript-react-full-redux-to-zustand-migration-t.ts (77 lines)
// Input: Redux Toolkit todo slice with CRUD + filter
// Output: Equivalent Zustand store with devtools + persist + immer
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface Todo { id: string; text: string; completed: boolean; }
type Filter = 'all' | 'active' | 'completed';
interface TodoState {
todos: Todo[];
filter: Filter;
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
setFilter: (filter: Filter) => void;
filteredTodos: () => Todo[];
}
const useTodoStore = create<TodoState>()(
devtools(
persist(
immer((set, get) => ({
todos: [],
filter: 'all' as Filter,
addTodo: (text) => set((state) => {
state.todos.push({ id: crypto.randomUUID(), text, completed: false });
}),
toggleTodo: (id) => set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}),
removeTodo: (id) => set((state) => {
state.todos = state.todos.filter((t) => t.id !== id);
}),
setFilter: (filter) => set({ filter }),
filteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case 'active': return todos.filter((t) => !t.completed);
case 'completed': return todos.filter((t) => t.completed);
default: return todos;
}
},
})),
{ name: 'todo-storage' }
),
{ name: 'TodoStore' }
)
);
TypeScript/React: Full Redux-to-Jotai migration (Todo app)
Full script: typescript-react-full-redux-to-jotai-migration-tod.ts (60 lines)
// Input: Redux Toolkit todo slice with CRUD + filter
// Output: Equivalent Jotai atoms with derived state
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
interface Todo { id: string; text: string; completed: boolean; }
type Filter = 'all' | 'active' | 'completed';
// Primitive atoms (replace Redux state)
const todosAtom = atomWithStorage<Todo[]>('todos', []);
const filterAtom = atom<Filter>('all');
// Write atoms (replace Redux actions)
const addTodoAtom = atom(null, (get, set, text: string) => {
set(todosAtom, [...get(todosAtom), {
id: crypto.randomUUID(), text, completed: false,
}]);
});
const toggleTodoAtom = atom(null, (get, set, id: string) => {
set(todosAtom, get(todosAtom).map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
));
});
// Derived atom (replaces createSelector)
const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
switch (filter) {
case 'active': return todos.filter((t) => !t.completed);
case 'completed': return todos.filter((t) => t.completed);
default: return todos;
}
});
TypeScript/React: Gradual coexistence — Redux + Zustand side by side
Full script: typescript-react-gradual-coexistence-redux-zustand.ts (35 lines)
// Input: App with Redux Provider, migrating one slice at a time
// Output: Both Redux and Zustand running simultaneously
import { Provider } from 'react-redux';
import { store } from './store/reduxStore'; // remaining Redux slices
import useAuthStore from './store/useAuthStore'; // migrated to Zustand
// Redux Provider stays until all slices are migrated
function App() {
return (
<Provider store={store}>
<Header /> {/* Uses Zustand (useAuthStore) */}
<Dashboard /> {/* Uses Redux (useSelector/useDispatch) */}
</Provider>
);
}
// Migrated component — Zustand, no Provider needed
function Header() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
return (
<header>
<span>{user?.name}</span>
<button onClick={logout}>Logout</button>
</header>
);
}
Anti-Patterns
Wrong: Wrapping Zustand store in a Provider like Redux
// ❌ BAD — Zustand does not need a Provider
const StoreContext = createContext(null);
function StoreProvider({ children }) {
const store = useMemo(() => create((set) => ({ count: 0 })), []);
return (
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>
);
}
Correct: Use Zustand hooks directly — no Provider needed
// ✅ GOOD — Zustand stores are module-scoped, import and use directly
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);
return <button onClick={increment}>{count}</button>;
}
Wrong: Selecting the entire Zustand store (causes unnecessary re-renders)
// ❌ BAD — Every state change re-renders this component
function UserProfile() {
const store = useUserStore(); // selects everything
return <span>{store.user.name}</span>;
}
Correct: Use granular selectors
// ✅ GOOD — Only re-renders when user.name changes
import { useShallow } from 'zustand/react/shallow';
function UserProfile() {
const name = useUserStore((s) => s.user.name);
return <span>{name}</span>;
}
// For multiple fields:
function UserCard() {
const { name, email } = useUserStore(
useShallow((s) => ({ name: s.user.name, email: s.user.email }))
);
return <div>{name} ({email})</div>;
}
Wrong: Mutating state directly in Zustand without Immer
// ❌ BAD — Direct mutation; Zustand won't detect the change
const useStore = create((set) => ({
todos: [],
addTodo: (todo) => set((state) => {
state.todos.push(todo); // Mutation! No new reference
return state;
}),
}));
Correct: Return new state objects, or use Immer middleware
// ✅ GOOD (option A) — Immutable update
const useStore = create((set) => ({
todos: [],
addTodo: (todo) => set((state) => ({
todos: [...state.todos, todo],
})),
}));
// ✅ GOOD (option B) — Immer middleware
import { immer } from 'zustand/middleware/immer';
const useStore = create(immer((set) => ({
todos: [],
addTodo: (todo) => set((state) => { state.todos.push(todo); }),
})));
Wrong: Creating atoms inside components (Jotai)
// ❌ BAD — New atom on every render, state is lost
function Counter() {
const countAtom = atom(0); // Re-created every render!
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Correct: Define atoms at module scope
// ✅ GOOD — Atom defined once at module scope
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Wrong: Using Zustand v5 with object selectors without useShallow
// ❌ BAD — Zustand v5 uses Object.is; new object every render = infinite loop
function UserInfo() {
const { name, email } = useUserStore((s) => ({
name: s.name,
email: s.email,
})); // Creates new object each time
}
Correct: Use useShallow for object selectors in Zustand v5
// ✅ GOOD — useShallow compares properties shallowly
import { useShallow } from 'zustand/react/shallow';
function UserInfo() {
const { name, email } = useUserStore(
useShallow((s) => ({ name: s.name, email: s.email }))
);
return <div>{name} ({email})</div>;
}
Common Pitfalls
- Selecting the entire store object: Calling
useStore()without a selector causes re-renders on every state change. Fix: Always pass a selector:useStore(s => s.specificField). For multiple fields, useuseShallow. [src1] - Wrong middleware ordering in Zustand:
immer(devtools(...))breaks DevTools. Fix: Correct order iscreate(devtools(persist(immer(...))))— devtools outermost, immer innermost. [src6] - Big-bang migration: Migrating all Redux slices at once stalls development and introduces regressions. Fix: Migrate one slice at a time; both libraries run side-by-side during transition. [src4]
- Zustand v5 strict equality causing infinite re-renders: Zustand v5 uses
Object.isby default. Returning a new object from a selector triggers re-renders. Fix: UseuseShalloworcreateWithEqualityFnwithshallowcomparator. [src6] - Treating Jotai atoms like Redux slices: Creating one giant atom with all state defeats Jotai's purpose. Fix: Break state into small, independent atoms. Compose with derived atoms. [src2]
- Not migrating server state to TanStack Query: Redux often manages API data. Zustand/Jotai are for client state. Fix: Move API data fetching to TanStack Query, keep only UI state in Zustand/Jotai. [src3]
- Losing Redux DevTools after migration: Forgetting to add
devtoolsmiddleware to Zustand stores. Fix: Wrap every store withdevtools(...)and give each a uniquename. [src3] - Mixing Redux dispatch patterns into Zustand: Calling
useStore.setState({ type: 'INCREMENT' })like a Redux action. Fix: Define actions as functions on the store and call them directly. [src5] - Relying on Zustand v4 persist initial-state behavior: v5 no longer writes the initial state to storage at store creation, and the persist race-condition fix only landed in v5.0.10 (further fixes through 5.0.12). Fix: pin Zustand >= 5.0.14 and set persisted values explicitly after hydration. [src6]
Diagnostic Commands
# Check Redux dependencies still in project
grep -rn "@reduxjs/toolkit\|react-redux\|redux-persist\|redux-saga" package.json
# Count remaining Redux usage in code
grep -rn "useSelector\|useDispatch\|createSlice\|createAsyncThunk" --include='*.ts' --include='*.tsx' | wc -l
# Check bundle impact (approximate gzipped sizes)
# zustand: ~3 KB | jotai: ~4 KB | @reduxjs/toolkit: ~14 KB
# Verify no duplicate state management providers
grep -rn "<Provider" --include='*.tsx' --include='*.jsx' | grep -v "node_modules"
# Check for atoms defined inside components (Jotai anti-pattern)
grep -rn "const.*Atom = atom(" --include='*.tsx' --include='*.ts' | grep -v "store\|atoms\|state"
# Check Zustand/Jotai versions installed
npm ls zustand jotai
Version History & Compatibility
| Library | Current Version | Min React | Key Changes | Notes |
|---|---|---|---|---|
| Zustand 5.x | 5.0.14 (May 2026) | React 18+, TS 4.5+ | Native useSyncExternalStore, strict Object.is equality, useShallow, create drops equality-fn arg, persist no longer stores initial state at creation | Use createWithEqualityFn if migrating from v4. Persist race-condition fix in v5.0.10; further fixes through 5.0.12–5.0.14. |
| Zustand 4.x | 4.5.7 | React 16.8+ | immer middleware, persist v2 | Upgrade to v5: remove deprecated equality fn; bump to React 18 first |
| Jotai 2.x | 2.20.0 (May 2026) | React 17+ | atomWithStorage, useAtomValue/useSetAtom, jotai/babel → jotai-babel | atomFamily deprecated, use jotai-family |
| Jotai 1.x | 1.13.1 | React 16.8+ | Initial stable release | Migrate to v2 API |
| Redux Toolkit 2.x | 2.5.0 (Jan 2026) | React 16.8+ | ESM-only, updated types | Source library (migrate from) |
When to Use / When Not to Use
| Use Zustand When | Use Jotai When | Stay on Redux When |
|---|---|---|
| Migrating from Redux (closest mental model) | Many independent UI states (form fields, toggles) | Large team with established Redux patterns |
| Need one centralized store per domain | Need fine-grained re-render optimization | Complex middleware chains (sagas, epics) |
| Server-side rendering with Next.js | Using React Suspense for async state | Regulatory/enterprise requirement for Redux |
| Small-medium app, rapid development | TypeScript-heavy project with computed state | Application already stable, no performance issues |
| Team familiar with Flux/Redux concepts | Bottom-up state composition | RTK Query handles all server state needs |
Important Caveats
- Zustand v5 dropped support for React <18. If your app uses React 17, use Zustand 4.x or choose Jotai instead.
- Zustand's
persistmiddleware stores state in localStorage by default. If your Redux store held sensitive data (tokens), configurepartializeto exclude secrets or usesessionStorage. - Jotai atoms defined inside components are re-created every render, losing state. Always define atoms at module scope or use
useMemowithatom(). - Redux Toolkit's Immer is built-in; Zustand requires explicitly adding the
immermiddleware. Without it,state.arr.push()silently fails to trigger re-renders. - Both Zustand and Jotai lack built-in equivalents to RTK Query's cache invalidation, optimistic updates, and polling. Use TanStack Query alongside them for server state.
- Zustand stores are singletons by default (module-scoped). For per-request isolation in SSR (Next.js), use
createStore+ React context pattern or the experimentalunstable_ssrSafemiddleware. - Jotai v2.17+ deprecated
atomFamilyinjotai/utils— use the separatejotai-familypackage instead. - Zustand v5 changed
persistbehavior: it no longer stores the initial state at store-creation time. If you relied on the v4 behavior, set the value explicitly after hydration. Pin Zustand >= 5.0.14 — the persist race-condition fix landed in v5.0.10 and further persist/rehydration fixes shipped through 5.0.12.