DEV Community

Cover image for I Migrated a React Native App from Redux to Zustand
Likhit Kumar V P
Likhit Kumar V P

Posted on

I Migrated a React Native App from Redux to Zustand

A real migration story: the 4 Zustand patterns I kept after moving a production React Native app off Redux including the v5 crash that burned us and the architectural boundary that changed how I think about state.

Environment: React Native 0.76+ · Zustand 5.x · TypeScript


TL;DR

If you want the short version before diving in:

  • Selectors + useShallow are non-negotiable in v5, object selectors without it cause an infinite loop crash, not just extra re-renders
  • The slice pattern scales a Zustand store across multiple domains without coupling them
  • devtools + persist + immer are the three middleware that make Zustand production-ready
  • Outside-React access via getState() and subscribe() is Zustand's most underrated advantage in React Native
  • The real win is the architectural boundary: Zustand for client state, React Query for server state and never crossing those lines

Three months into migrating a production React Native app from Redux to Zustand, my colleague sent a Slack message that stopped me cold: "why does the filter screen crash every time someone searches for something?"

The answer turned out to be a single line: an object selector without useShallow. In Zustand v5, that's not a performance problem, it's an infinite render loop that throws Maximum update depth exceeded and unmounts your component tree. We caught it in staging. Barely.

That moment crystallised something about Zustand: it rewards people who understand what's happening under the hood, and it punishes cargo-culting in a very specific, memorable way. After the migration, I came away with four patterns that genuinely changed how I think about client state architecture. This post is all of them plus where Zustand falls short, how it pairs with React Query, and the boundary every senior dev should draw before writing a single line of state.

This isn't a beginner's tour. We'll go deep.


What Zustand Actually Is (and Isn't)

Before the patterns, the boundary that matters most.

Zustand is a client state manager. It manages state that originates and lives inside your application UI state, navigation context, user preferences, ephemeral session data, filter selections. It has no concept of staleness, no cache invalidation, no background refetching. It doesn't know your backend exists.

This distinction isn't pedantic. It's the most common architectural mistake made with Zustand: using it to store data fetched from an API and then manually wiring up all the synchronisation logic that a server state tool like React Query or SWR would give you for free. We'll revisit this in the architecture section.

What Zustand is: a minimal, subscription-based state container built on a publish/subscribe pattern, with a React hook interface on top.

What it isn't: a server cache, a Redux replacement for all use cases, or a global state dumping ground.


How It Works Under the Hood

Understanding Zustand's internals is what separates confident usage from cargo-culting and explains why some footguns are so easy to step on.

At its core, Zustand creates a store, a closure that holds state and a setState function. This is framework-agnostic. The React integration is a thin layer on top using useSyncExternalStore, React's official API for subscribing to external stores.

// This is essentially what Zustand's vanilla store does
const createStore = (initializer) => {
  let state = initializer(setState, getState);
  const listeners = new Set();

  function setState(partial) {
    const next = typeof partial === 'function' ? partial(state) : partial;
    state = { ...state, ...next };
    listeners.forEach(listener => listener(state));
  }

  function getState() { return state; }

  function subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }

  return { getState, setState, subscribe };
};
Enter fullscreen mode Exit fullscreen mode

When a component calls useStore(selector), it subscribes to the store and re-renders only when the selected slice changes. This is the key performance insight: Zustand re-renders are selector-scoped, not store-wide.

This is fundamentally different from React Context, where any context value change re-renders all consumers. It's also why Zustand outperforms naive Context usage at scale without requiring memoisation gymnastics.

Zustand v5 note: v5 dropped the use-sync-external-store shim package as a runtime dependency for the main create path, using the native useSyncExternalStore built into React 18 directly. This is why React 18 is now the minimum required version. The shim remains a peer dependency for zustand/traditional (for createWithEqualityFn and legacy equality patterns), but for standard usage you no longer need it. v5 is purely about dropping legacy support and tightening the foundation.


Setup

Installation is a single dependency with no peer requirements:

npm install zustand
Enter fullscreen mode Exit fullscreen mode

No additional configuration for React Native, it works with both the old and new architecture (Bridgeless/JSI). TypeScript works out of the box with the curried create<T>()() syntax, which is required for correct type inference with middleware:

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

type FilterMode = 'all' | 'active' | 'completed';

interface FilterState {
  mode: FilterMode;
  searchQuery: string;
  setMode: (mode: FilterMode) => void;
  setSearchQuery: (query: string) => void;
  reset: () => void;
}

export const useFilterStore = create<FilterState>()((set, _get, store) => ({
  mode: 'all',
  searchQuery: '',
  setMode: (mode) => set({ mode }),
  setSearchQuery: (query) => set({ searchQuery: query }),
  // store.getInitialState() — the v5-recommended reset pattern.
  // The older approach (capturing a separate `initialState` object and calling
  // set(initialState)) still works, but getInitialState() is cleaner because
  // the store itself guarantees the source of truth rather than a variable that
  // could drift.
  reset: () => set(store.getInitialState()),
}));
Enter fullscreen mode Exit fullscreen mode

A few deliberate choices here: state and actions live together in the same definition, and the interface is explicit rather than inferred. At scale, explicit types are worth the verbosity.


Pattern 1: Selectors with useShallow : The Performance Foundation (and the Crash We Almost Shipped)

This is where most teams leave performance on the table and where we almost shipped a crash to production.

Every store call in a component should use a selector:

// ❌ Subscribes to the entire store — re-renders on any state change
const store = useFilterStore();

// ✅ Only re-renders when `mode` changes
const mode = useFilterStore(state => state.mode);
Enter fullscreen mode Exit fullscreen mode

The trap with object selectors in v5 is more severe than a performance issue, it causes React to throw Maximum update depth exceeded, which unmounts your component tree. Zustand v5 uses useSyncExternalStore directly, which requires stable selector references. A selector that returns a new object on every call creates a reference that never stabilises, triggering an infinite reconciliation loop before React bails out:

// Both of these import paths are valid in v5:
import { useShallow } from 'zustand/react/shallow'; // recommended in the prevent-rerenders guide
// import { useShallow } from 'zustand/shallow';    // used in the v5 migration guide

// ❌ Returns a new object on every render
// In v5 this doesn't cause extra re-renders — it throws:
// "Uncaught Error: Maximum update depth exceeded"
const { mode, searchQuery } = useFilterStore(
  state => ({ mode: state.mode, searchQuery: state.searchQuery })
);

// ✅ useShallow stabilises the reference — only re-renders if values actually change
const { mode, searchQuery } = useFilterStore(
  useShallow(state => ({ mode: state.mode, searchQuery: state.searchQuery }))
);
Enter fullscreen mode Exit fullscreen mode

v5 breaking change, this is a render error, not just a perf issue: In v4, you could pass shallow as a second argument to the store hook (e.g. useStore(selector, shallow)). In v5, this signature was removed. Object selectors without useShallow now cause React to throw a maximum update depth error that unmounts the affected component. This is the single most common crash teams hit when migrating from v4 to v5. Don't skip it.

In React Native, where component tree errors surface as blank screens, this distinction matters critically. useShallow for all object selectors is non-negotiable in v5.


Pattern 2: The Slice Architecture : One Store, Multiple Concerns

For large apps, a monolithic store with 40 fields and 30 actions becomes hard to reason about fast. The slice pattern lets you compose a single store from multiple logical domains while keeping each domain's code self-contained.

The insight I didn't expect during the migration: decomposing the Redux store into slices was actually easier than I anticipated, because the domains were already mostly independent. The coupling I'd assumed existed mostly didn't. That said, the key is typing each slice's set with Zustand's StateCreator type, something most examples skip but which is essential for correct TypeScript inference:

// store/slices/uiSlice.ts
import { StateCreator } from 'zustand';

export interface UISlice {
  isBottomSheetOpen: boolean;
  activeTab: string;
  openBottomSheet: () => void;
  closeBottomSheet: () => void;
  setActiveTab: (tab: string) => void;
}

// StateCreator<BoundStore, [], [], UISlice> gives set correct knowledge
// of the full composed store shape — essential when slices reference each other
export const createUISlice: StateCreator<
  UISlice & SessionSlice, [], [], UISlice
> = (set) => ({
  isBottomSheetOpen: false,
  activeTab: 'home',
  openBottomSheet: () => set({ isBottomSheetOpen: true }),
  closeBottomSheet: () => set({ isBottomSheetOpen: false }),
  setActiveTab: (tab) => set({ activeTab: tab }),
});

// store/slices/sessionSlice.ts
import { StateCreator } from 'zustand';

export interface SessionSlice {
  userId: string | null;
  locale: string;
  setUserId: (id: string | null) => void;
  setLocale: (locale: string) => void;
}

export const createSessionSlice: StateCreator<
  UISlice & SessionSlice, [], [], SessionSlice
> = (set) => ({
  userId: null,
  locale: 'en',
  setUserId: (id) => set({ userId: id }),
  setLocale: (locale) => set({ locale }),
});

// store/useBoundStore.ts — composed store
import { create } from 'zustand';
import { createUISlice, UISlice } from './slices/uiSlice';
import { createSessionSlice, SessionSlice } from './slices/sessionSlice';

type BoundStore = UISlice & SessionSlice;

export const useBoundStore = create<BoundStore>()((...args) => ({
  ...createUISlice(...args),
  ...createSessionSlice(...args),
}));
Enter fullscreen mode Exit fullscreen mode

Each slice owns its state shape and actions. Consuming components import from useBoundStore with a selector scoped to the slice they need. No coupling between slices unless intentionally designed.

The rule of thumb for when to use slices vs separate stores: if two domains need to read each other's state inside an action, they belong in the same store. If they're genuinely independent, separate stores are cleaner:

// These are genuinely independent, separate stores are correct
export const useThemeStore = create<ThemeState>()(...);
export const useOnboardingStore = create<OnboardingState>()(...);
Enter fullscreen mode Exit fullscreen mode

Pattern 3: The Middleware Stack : devtools + persist + immer

This is where Zustand earns its production credibility. Three middleware pieces, each solving a distinct problem. What I discovered in the migration is that none of these are nice-to-haves.

devtools : Non-Negotiable in Development

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

export const useFilterStore = create<FilterState>()(
  devtools(
    (set) => ({
      ...initialState,
      setMode: (mode) => set({ mode }, false, 'filter/setMode'),
      reset: () => set(initialState, false, 'filter/reset'),
    }),
    { name: 'FilterStore' }
  )
);
Enter fullscreen mode Exit fullscreen mode

The third argument to set inside devtools is the action name, it appears in Redux DevTools (which Zustand integrates with via the same browser extension). Named actions make debugging dramatically easier. Make them a habit, not an afterthought.

persist : Surviving App Restarts

For state that should survive app kills (theme, locale, onboarding status, form drafts):

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface ThemeState {
  colorScheme: 'light' | 'dark' | 'system';
  setColorScheme: (scheme: 'light' | 'dark' | 'system') => void;
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      colorScheme: 'system',
      setColorScheme: (scheme) => set({ colorScheme: scheme }),
    }),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => AsyncStorage),
      // Persist only state fields, never actions — keeps storage lean
      partialize: (state) => ({ colorScheme: state.colorScheme }),
      version: 1,
      migrate: (persistedState, version) => {
        if (version === 0) {
          // Migrate from v0 shape to v1 — add new field with default
          return { ...(persistedState as ThemeState), colorScheme: 'system' };
        }
        return persistedState as ThemeState;
      },
    }
  )
);
Enter fullscreen mode Exit fullscreen mode

Two things most teams skip until they're burned:

partialize: Persist only the state fields, never actions. Persisting functions causes serialisation errors and inflates storage unnecessarily.

version + migrate: If you change the shape of persisted state in a new release, users on the old shape will hydrate into broken state on upgrade. Version from day one, it costs nothing upfront and saves a painful hotfix later.

v5 persist change: In v4 (up to 4.5.4), the initial state was automatically written to storage on store creation. In v5 this was removed. If you were relying on initial state being persisted without any user interaction, you'll need to explicitly write it after store creation. This is a silent migration gotcha that burned several teams.

immer : Ergonomic Nested Updates

For deeply nested state, Zustand's immer middleware lets you write mutating syntax that produces immutable updates under the hood:

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface FormState {
  fields: {
    name: { value: string; error: string | null };
    email: { value: string; error: string | null };
  };
  setFieldValue: (field: keyof FormState['fields'], value: string) => void;
  setFieldError: (field: keyof FormState['fields'], error: string | null) => void;
}

export const useFormStore = create<FormState>()(
  immer((set) => ({
    fields: {
      name: { value: '', error: null },
      email: { value: '', error: null },
    },
    setFieldValue: (field, value) =>
      set((state) => {
        state.fields[field].value = value; // Looks mutating, produces immutable update
      }),
    setFieldError: (field, error) =>
      set((state) => {
        state.fields[field].error = error;
      }),
  }))
);
Enter fullscreen mode Exit fullscreen mode

Without immer, nested updates require verbose spread chains. Use it selectively, for flat state structures, it adds overhead with no benefit.


Pattern 4: Outside-React Access : The React Native Superpower

This pattern surprised me most during the migration. On Redux, accessing store state outside a component meant importing the store object and calling store.getState() : possible, but architecturally messy because the Redux store is a singleton you're supposed to connect through middleware. Zustand is different in a meaningful way.

React Native apps frequently need store access in non-component contexts : navigation handlers, push notification callbacks, Axios interceptors, analytics services. Every Zustand store exposes getState(), setState(), and subscribe() directly on the store object, no Provider boundary to escape, no hook rules to worry about:

import { useSessionStore } from './store/useSessionStore';

// --- In an Axios request interceptor (app init, not a component) ---
axiosInstance.interceptors.request.use((config) => {
  const userId = useSessionStore.getState().userId;
  if (userId) config.headers['X-User-Id'] = userId;
  return config;
});

// --- In a push notification handler ---
import { useUIStore } from './store/useUIStore';

Notifications.addNotificationReceivedListener((notification) => {
  if (notification.request.content.data.type === 'alert') {
    useUIStore.setState({ isBottomSheetOpen: true });
  }
});

// --- Subscribing to state changes outside React ---
// Requires subscribeWithSelector middleware on the store (see note below)
import { subscribeWithSelector } from 'zustand/middleware';

const useSessionStoreWithSelector = create<SessionState>()(
  subscribeWithSelector((set) => ({
    userId: null,
    setUserId: (id) => set({ userId: id }),
  }))
);

const unsubscribe = useSessionStoreWithSelector.subscribe(
  (state) => state.userId,           // selector
  (userId) => {
    analytics.identify(userId);      // callback — fires only when userId changes
  }
);

// Clean up when no longer needed
// unsubscribe();
Enter fullscreen mode Exit fullscreen mode

Important: The two-argument subscribe(selector, callback) signature is not available on a basic Zustand store. It requires the subscribeWithSelector middleware to be applied during store creation. Without it, TypeScript will error and the selector argument will be silently ignored in JavaScript. If you only need the full-state listener (one argument), no middleware is needed.

This outside-React pattern is particularly valuable for:

  • Attaching user context to API request interceptors at app init
  • Syncing store state with deep-link handlers
  • Reading store state inside notification handler callbacks
  • Triggering analytics events when specific state slices change

The elegance here is that none of this requires any special Redux-style plumbing. The store is just a closure with a clean interface. That simplicity is Zustand's real design achievement.


The Architectural Boundary: Zustand vs React Query

This is the decision that matters most, and it deserves direct treatment.

┌─────────────────────────────────────────────────────────────────┐
│                     Application State                           │
├───────────────────────────┬─────────────────────────────────────┤
│       Client State        │          Server State               │
│       (Zustand)           │       (React Query / SWR)           │
├───────────────────────────┼─────────────────────────────────────┤
│ • Active filter/sort      │ • API response data                 │
│ • Selected item ID        │ • Paginated lists                   │
│ • Modal open/closed       │ • User profile from backend         │
│ • Theme / locale          │ • Notifications from API            │
│ • Onboarding step         │ • Any data with a TTL               │
│ • Form draft state        │ • Data shared across screens        │
│ • Navigation history      │ • Data that needs background sync   │
└───────────────────────────┴─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The test is simple: does this data have a source of truth on a server? If yes, it belongs in a server state manager. If the source of truth is the user's current session and nothing more, it belongs in Zustand.

Where teams go wrong is storing API responses in Zustand. You end up building a manual cache: loading flags, error states, refetch logic, staleness checks, deduplication. It works until it doesn't, and debugging cache consistency bugs across screens in production is genuinely unpleasant. I know because we had exactly that problem in the Redux version, one of the primary reasons for the migration.

The two tools compose cleanly and coordinate naturally:

import { useQuery } from '@tanstack/react-query';
import { useFilterStore } from '../store/useFilterStore';

function ItemListScreen() {
  // Client state — Zustand owns this
  const filterMode = useFilterStore(state => state.mode);
  const setFilter = useFilterStore(state => state.setMode);

  // Server state — React Query owns this
  // isPending is the correct v5 flag — isLoading was redefined in v5
  // as `isPending && isFetching`, which returns false when enabled: false
  const { data: items, isPending } = useQuery({
    queryKey: ['items', filterMode],
    queryFn: () => api.getItems({ filter: filterMode }),
  });

  // filterMode from Zustand flows into the React Query key.
  // When the user changes the filter, Zustand updates, the key changes,
  // React Query fires a new fetch automatically — no manual wiring needed.
}
Enter fullscreen mode Exit fullscreen mode

The elegance of this composition Zustand filter state flowing directly into a React Query cache key is something I didn't fully appreciate until I saw it eliminate about 60 lines of manual sync logic from our old Redux middleware. That's the real win from drawing the boundary correctly.


Testing Zustand Stores

Stores are plain JavaScript objects testing them without React is one of Zustand's most underappreciated advantages:

// __tests__/filterStore.test.ts
import { useFilterStore } from '../store/useFilterStore';

describe('FilterStore', () => {
  beforeEach(() => {
    // Reset store to initial state between tests
    useFilterStore.setState({ mode: 'all', searchQuery: '' });
  });

  it('updates filter mode', () => {
    useFilterStore.getState().setMode('completed');
    expect(useFilterStore.getState().mode).toBe('completed');
  });

  it('resets to initial state', () => {
    useFilterStore.setState({ mode: 'active', searchQuery: 'hello' });
    useFilterStore.getState().reset();

    const { mode, searchQuery } = useFilterStore.getState();
    expect(mode).toBe('all');
    expect(searchQuery).toBe('');
  });
});
Enter fullscreen mode Exit fullscreen mode

No mocking, no test renderers, no async ceremony. Pure store logic tests run fast and are straightforward to reason about. For components, test via @testing-library/react-native as normal, Zustand stores hydrate naturally in the test environment without Provider wrappers. This alone made our test suite meaningfully faster after the migration.


Common Pitfalls

Over-globalising state. Not every piece of state needs to be global. If state is only used by one screen, useState is still the right call. Reach for Zustand when state genuinely needs to be shared across component trees or accessed outside components.

Storing server data in Zustand. Already covered, it leads to a manual cache implementation that's harder to build and maintain than just using React Query.

Object selectors without useShallow in v5. const store = useStore() subscribes to every field. Object selectors without useShallow cause React to throw a maximum update depth error in v5. Always use primitive selectors or useShallow for multi-field selections. No exceptions.

Not versioning persisted state. The first time you change a persisted store's shape without a migration, a subset of users will see broken state on upgrade. Version from the start.

Using subscribe(selector, callback) without subscribeWithSelector. This is a silent failure in JS and a TypeScript error in TS. If you need selector-scoped subscriptions outside React, apply the middleware during store creation.


When to Reach for Something Else

Zustand is not always the answer:

  • React Query / SWR : for any server data. Full stop.
  • useState / useReducer : for local component state that doesn't need sharing
  • Jotai : if you prefer an atomic model and want fine-grained subscriptions without selectors
  • Redux Toolkit : if your team has deep Redux investment, needs time-travel debugging, or has complex cross-cutting middleware requirements

Zustand sits in a sweet spot: minimal API, near-zero boilerplate, excellent performance, and just enough structure to scale. For most React Native apps managing client state, it's the right default in 2026.


What the Migration Actually Changed

After going through this, the biggest shift wasn't in the code, it was in how we reason about state. Redux pushed us toward treating everything as events dispatched into a central pipeline. That's powerful, but it also meant state decisions lived far from the components that needed them, and debugging required jumping between actions, reducers, and selectors that were never in the same file.

Zustand pushed state decisions back toward the components, while still giving us the global access we need. The result was a codebase that new engineers could read without a mental model of the entire event graph.

If you're on Redux and feeling the friction, the migration is more mechanical than it seems. The patterns in this post should give you enough foundation to make that call confidently.


Key Takeaways

Object selectors without useShallow cause render errors in v5. Zustand v5 uses native useSyncExternalStore, which requires stable selector references. A selector returning a new object on every render triggers Maximum update depth exceeded. Always use useShallow for any multi-field object selector. This is the number one migration crash from v4.

The slice pattern scales cleanly but type your StateCreator. Untyped set in slice functions is a common gap in examples. Use StateCreator<BoundStore, [], [], SliceType> for correct inference.

The middleware stack is where Zustand earns production credibility. devtools with named actions, persist with versioning, immer for nested state.

Outside-React access is a genuine architectural advantage. getState(), setState(), and subscribe() work anywhere. For selector-scoped subscriptions outside React, apply subscribeWithSelector middleware.

The client/server boundary is the most important decision. Zustand for client state, React Query for server state. Don't cross the streams.


What does your current React Native state setup look like, still on Redux, or already on Zustand? Did you hit the useShallow crash in v5? I'm curious where other teams draw the line between client and server state. Drop a comment below.


Top comments (0)