Modern State Management in React Native: Zustand and TanStack Query in Practice

A practical guide to modern React Native state management using Zustand for client state and TanStack Query for server state, with MMKV persistence, offline support, optimistic updates, and a clear Redux migration path.

Modern State Management in React Native: Zustand and TanStack Query in Practice

State management in React Native has changed a lot over the past few years — and honestly, it's about time. The days of wiring up every API response through Redux reducers, hand-writing action creators for every single endpoint, and maintaining normalized caches by hand are (thankfully) behind us. In 2026, the consensus is pretty clear: separate your client state from your server state, pick the right tool for each job, and keep things simple.

This guide walks you through a modern, production-ready state management setup for React Native apps. We'll combine Zustand for lightweight client state with TanStack Query (React Query) v5 for server state — including MMKV persistence, offline support, optimistic updates, and real-world patterns you can actually drop into your app today.

Why the Old Approach Fell Apart

If you spent any time building React Native apps between 2018 and 2022, you probably lived in Redux land. Every piece of data — whether it came from an API, a local toggle, or a user preference — went into the same global store. It worked, technically. But it came with some serious pain points:

  • Boilerplate overload: Action types, action creators, reducers, selectors, thunks or sagas — all for a single API call
  • Cache invalidation nightmares: When should stale data get refetched? You had to build that logic yourself
  • No built-in loading/error states: Every API interaction needed its own isLoading, error, and data fields in the store
  • Server and client state mixed together: User preferences, UI toggles, and API responses all lived in the same flat structure

The fundamental insight that changed everything? Server state and client state are fundamentally different things — and they should be managed by different tools.

Server State vs. Client State

Server state is data that lives on a remote server and is shared across users. It's asynchronous, can become stale, and needs caching, background refetching, and synchronization. Think user profiles, product lists, notifications, messages.

Client state is different. It lives entirely in the app and is controlled by the user. It's synchronous, always up-to-date, and doesn't need caching. Think: is the sidebar open? Which theme is selected? What's the current form input? Has the user completed onboarding?

Once you see this distinction, the architecture becomes kind of obvious: use TanStack Query for server state and Zustand for client state. Between them, they cover roughly 95% of what you'd use Redux for — with a fraction of the code.

Setting Up the Project

Let's start from scratch with a clean Expo project and install everything we need.

# Create a new Expo project
npx create-expo-app@latest StateManagementDemo
cd StateManagementDemo

# Install state management dependencies
npx expo install zustand @tanstack/react-query react-native-mmkv

# Install additional utilities
npx expo install @react-native-community/netinfo

Here's what each package does:

  • zustand: Minimal client state management (~1KB gzipped — yes, really)
  • @tanstack/react-query: Server state management with caching, refetching, and mutations
  • react-native-mmkv: High-performance key-value storage (about 30x faster than AsyncStorage)
  • @react-native-community/netinfo: Network connectivity detection for online/offline handling

Zustand: Client State Done Right

Zustand (German for "state") is a small, fast state management library built by the same team behind Jotai and React Three Fiber. No providers, no context wrappers, no boilerplate. You create a store, export hooks, and use them in your components. That's it.

Your First Zustand Store

Let's start with a basic store for managing app-level UI state:

// stores/useAppStore.ts
import { create } from 'zustand';

interface AppState {
  theme: 'light' | 'dark' | 'system';
  hasCompletedOnboarding: boolean;
  sidebarOpen: boolean;

  // Actions
  setTheme: (theme: 'light' | 'dark' | 'system') => void;
  completeOnboarding: () => void;
  toggleSidebar: () => void;
}

export const useAppStore = create<AppState>((set) => ({
  theme: 'system',
  hasCompletedOnboarding: false,
  sidebarOpen: false,

  setTheme: (theme) => set({ theme }),
  completeOnboarding: () => set({ hasCompletedOnboarding: true }),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));

That's the whole thing. No provider wrapping your app, no context, no action types. Using it in a component is equally straightforward:

// components/ThemeToggle.tsx
import { useAppStore } from '../stores/useAppStore';
import { Pressable, Text } from 'react-native';

export function ThemeToggle() {
  const theme = useAppStore((state) => state.theme);
  const setTheme = useAppStore((state) => state.setTheme);

  const nextTheme = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';

  return (
    <Pressable onPress={() => setTheme(nextTheme)}>
      <Text>Current: {theme} — Tap to switch</Text>
    </Pressable>
  );
}

Notice the selector pattern: useAppStore((state) => state.theme). This matters more than you might think. By selecting only the specific slice of state you need, the component only re-renders when that specific value changes. If sidebarOpen toggles, this component won't re-render because it only subscribes to theme.

Persisting State with MMKV

For a mobile app, some client state needs to survive app restarts — the user's theme preference, whether they've seen onboarding, auth tokens, and so on. Zustand's persist middleware makes this trivial, and pairing it with MMKV gives you persistence that's roughly 30x faster than AsyncStorage.

First, create a storage adapter:

// lib/storage.ts
import { MMKV } from 'react-native-mmkv';
import { StateStorage } from 'zustand/middleware';

export const mmkvStorage = new MMKV();

export const zustandMMKVStorage: StateStorage = {
  setItem: (name, value) => {
    mmkvStorage.set(name, value);
  },
  getItem: (name) => {
    const value = mmkvStorage.getString(name);
    return value ?? null;
  },
  removeItem: (name) => {
    mmkvStorage.delete(name);
  },
};

Now wrap your store with the persist middleware:

// stores/useAppStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { zustandMMKVStorage } from '../lib/storage';

interface AppState {
  theme: 'light' | 'dark' | 'system';
  hasCompletedOnboarding: boolean;
  sidebarOpen: boolean;

  setTheme: (theme: 'light' | 'dark' | 'system') => void;
  completeOnboarding: () => void;
  toggleSidebar: () => void;
}

export const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      theme: 'system',
      hasCompletedOnboarding: false,
      sidebarOpen: false,

      setTheme: (theme) => set({ theme }),
      completeOnboarding: () => set({ hasCompletedOnboarding: true }),
      toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
    }),
    {
      name: 'app-store',
      storage: createJSONStorage(() => zustandMMKVStorage),
      // Only persist specific fields — not everything
      partialize: (state) => ({
        theme: state.theme,
        hasCompletedOnboarding: state.hasCompletedOnboarding,
        // Note: sidebarOpen is NOT persisted — it resets on app restart
      }),
    }
  )
);

The partialize option is key. You probably don't want to persist transient UI state like whether a sidebar is open. By specifying exactly which fields to persist, you keep your storage lean and avoid bugs where stale UI state leaks across sessions.

Authentication Store Pattern

Here's a more realistic example — an authentication store that manages tokens, user data, and auth state:

// stores/useAuthStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { zustandMMKVStorage } from '../lib/storage';

interface User {
  id: string;
  email: string;
  displayName: string;
  avatarUrl: string | null;
}

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;

  setAuth: (user: User, accessToken: string, refreshToken: string) => void;
  updateUser: (updates: Partial<User>) => void;
  setAccessToken: (token: string) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,

      setAuth: (user, accessToken, refreshToken) =>
        set({ user, accessToken, refreshToken, isAuthenticated: true }),

      updateUser: (updates) =>
        set((state) => ({
          user: state.user ? { ...state.user, ...updates } : null,
        })),

      setAccessToken: (accessToken) => set({ accessToken }),

      logout: () =>
        set({
          user: null,
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false,
        }),
    }),
    {
      name: 'auth-store',
      storage: createJSONStorage(() => zustandMMKVStorage),
    }
  )
);

This store persists automatically. When the user closes and reopens the app, their auth state is restored from MMKV in milliseconds — no splash screen delay while AsyncStorage slowly deserializes everything.

TanStack Query: Server State Made Simple

TanStack Query (formerly React Query) handles everything related to server data: fetching, caching, background refetching, pagination, infinite scrolling, mutations, and optimistic updates. It pretty much eliminates the need to manually track loading states, error states, or cache invalidation logic.

Setting Up the Query Client

First, configure the query client with sensible defaults for a mobile app:

// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Keep data fresh for 5 minutes
      staleTime: 5 * 60 * 1000,
      // Cache data for 30 minutes even when unused
      gcTime: 30 * 60 * 1000,
      // Retry failed requests twice
      retry: 2,
      // Don't refetch when app regains focus on mobile
      // (we handle this more carefully below)
      refetchOnWindowFocus: false,
    },
    mutations: {
      retry: 1,
    },
  },
});

Then wrap your app with the QueryClientProvider:

// app/_layout.tsx (Expo Router)
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../lib/queryClient';
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack />
    </QueryClientProvider>
  );
}

Building a Type-Safe API Layer

Before writing queries, let's set up a clean API layer that handles authentication and errors consistently:

// lib/api.ts
import { useAuthStore } from '../stores/useAuthStore';

const API_BASE = 'https://api.example.com/v1';

class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'ApiError';
  }
}

async function fetchWithAuth(endpoint: string, options: RequestInit = {}) {
  const { accessToken, logout } = useAuthStore.getState();

  const response = await fetch(`${API_BASE}${endpoint}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
      ...options.headers,
    },
  });

  if (response.status === 401) {
    logout();
    throw new ApiError(401, 'Session expired');
  }

  if (!response.ok) {
    const errorBody = await response.text();
    throw new ApiError(response.status, errorBody);
  }

  return response.json();
}

export const api = {
  get: (endpoint: string) => fetchWithAuth(endpoint),
  post: (endpoint: string, data: unknown) =>
    fetchWithAuth(endpoint, { method: 'POST', body: JSON.stringify(data) }),
  put: (endpoint: string, data: unknown) =>
    fetchWithAuth(endpoint, { method: 'PUT', body: JSON.stringify(data) }),
  patch: (endpoint: string, data: unknown) =>
    fetchWithAuth(endpoint, { method: 'PATCH', body: JSON.stringify(data) }),
  delete: (endpoint: string) =>
    fetchWithAuth(endpoint, { method: 'DELETE' }),
};

Notice how we access the Zustand auth store directly using useAuthStore.getState() — no hooks needed outside of components. This is honestly one of Zustand's best features: the store is accessible anywhere in your codebase, not just inside React components.

Writing Your First Query

Now let's actually fetch some data. Here's a custom hook for loading a user's list of projects:

// hooks/useProjects.ts
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';

interface Project {
  id: string;
  name: string;
  description: string;
  createdAt: string;
  memberCount: number;
}

export function useProjects() {
  return useQuery({
    queryKey: ['projects'],
    queryFn: () => api.get('/projects') as Promise<Project[]>,
  });
}

Using it in a component gives you loading, error, and data states for free:

// screens/ProjectsScreen.tsx
import { View, Text, FlatList, ActivityIndicator } from 'react-native';
import { useProjects } from '../hooks/useProjects';

export function ProjectsScreen() {
  const { data: projects, isLoading, error, refetch } = useProjects();

  if (isLoading) {
    return <ActivityIndicator size="large" />;
  }

  if (error) {
    return (
      <View>
        <Text>Failed to load projects</Text>
        <Pressable onPress={() => refetch()}>
          <Text>Retry</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <FlatList
      data={projects}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View>
          <Text>{item.name}</Text>
          <Text>{item.description}</Text>
        </View>
      )}
    />
  );
}

Behind the scenes, TanStack Query is handling caching, background refetching, deduplication (if two components use the same query, only one network request fires), and garbage collection. All that stuff you used to write yourself with Redux? Gone.

Advanced Patterns: Infinite Scrolling

Mobile apps frequently need paginated lists — think feeds, notification lists, search results. TanStack Query's useInfiniteQuery makes this surprisingly straightforward:

// hooks/useNotifications.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { api } from '../lib/api';

interface Notification {
  id: string;
  title: string;
  body: string;
  read: boolean;
  createdAt: string;
}

interface PaginatedResponse {
  data: Notification[];
  nextCursor: string | null;
  hasMore: boolean;
}

export function useNotifications() {
  return useInfiniteQuery({
    queryKey: ['notifications'],
    queryFn: ({ pageParam }) =>
      api.get(`/notifications?cursor=${pageParam}&limit=20`) as Promise<PaginatedResponse>,
    initialPageParam: '',
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.nextCursor : undefined,
    // Limit stored pages to prevent memory bloat
    maxPages: 10,
  });
}

The component wires up infinite scrolling with FlatList's onEndReached:

// screens/NotificationsScreen.tsx
import { FlatList, ActivityIndicator, View, Text } from 'react-native';
import { useNotifications } from '../hooks/useNotifications';

export function NotificationsScreen() {
  const {
    data,
    isLoading,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useNotifications();

  // Flatten all pages into a single array
  const notifications = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <FlatList
      data={notifications}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View style={{ padding: 16 }}>
          <Text style={{ fontWeight: item.read ? 'normal' : 'bold' }}>
            {item.title}
          </Text>
          <Text>{item.body}</Text>
        </View>
      )}
      onEndReached={() => {
        if (hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      }}
      onEndReachedThreshold={0.5}
      ListFooterComponent={
        isFetchingNextPage ? <ActivityIndicator style={{ padding: 16 }} /> : null
      }
    />
  );
}

The maxPages: 10 option is a v5 feature that's worth knowing about. It prevents memory from growing unbounded in long lists. When the user scrolls past 10 pages, the oldest pages get dropped from the cache. If they scroll back, TanStack Query refetches them seamlessly.

Mutations and Optimistic Updates

Mutations handle creating, updating, and deleting data. In a mobile app, optimistic updates are pretty much essential for making the UI feel responsive — you update the UI immediately, then sync with the server in the background.

Basic Mutation

// hooks/useCreateProject.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';

interface CreateProjectInput {
  name: string;
  description: string;
}

export function useCreateProject() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateProjectInput) =>
      api.post('/projects', input),
    onSuccess: () => {
      // Invalidate the projects list so it refetches
      queryClient.invalidateQueries({ queryKey: ['projects'] });
    },
  });
}

Optimistic Update Pattern

For actions where you want instant UI feedback — like marking a notification as read — optimistic updates make the experience feel truly native:

// hooks/useMarkNotificationRead.ts
import { useMutation, useQueryClient, InfiniteData } from '@tanstack/react-query';
import { api } from '../lib/api';

interface PaginatedResponse {
  data: Notification[];
  nextCursor: string | null;
  hasMore: boolean;
}

export function useMarkNotificationRead() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (notificationId: string) =>
      api.patch(`/notifications/${notificationId}`, { read: true }),

    onMutate: async (notificationId) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ['notifications'] });

      // Snapshot the previous value
      const previousData = queryClient.getQueryData<
        InfiniteData<PaginatedResponse>
      >(['notifications']);

      // Optimistically update the cache
      queryClient.setQueryData<InfiniteData<PaginatedResponse>>(
        ['notifications'],
        (old) => {
          if (!old) return old;
          return {
            ...old,
            pages: old.pages.map((page) => ({
              ...page,
              data: page.data.map((notification) =>
                notification.id === notificationId
                  ? { ...notification, read: true }
                  : notification
              ),
            })),
          };
        }
      );

      // Return the snapshot so we can roll back on error
      return { previousData };
    },

    onError: (_err, _notificationId, context) => {
      // Roll back to the previous value on error
      if (context?.previousData) {
        queryClient.setQueryData(['notifications'], context.previousData);
      }
    },

    onSettled: () => {
      // Refetch after mutation to ensure server state is synced
      queryClient.invalidateQueries({ queryKey: ['notifications'] });
    },
  });
}

This pattern follows a clear flow: cancel in-flight queries, snapshot the current cache, apply the optimistic update, roll back on error, and refetch on settlement. The UI updates the instant the user taps, and if the server request fails, the change gets automatically reverted. Users won't even notice the round trip in most cases.

Online/Offline Handling

Mobile apps need to handle network connectivity gracefully — this isn't optional. TanStack Query has built-in support for this, and we can extend it with the NetInfo library for React Native.

// lib/onlineManager.ts
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';

export function setupOnlineManager() {
  onlineManager.setEventListener((setOnline) => {
    return NetInfo.addEventListener((state) => {
      setOnline(!!state.isConnected);
    });
  });
}

Initialize it in your app entry point:

// app/_layout.tsx
import { useEffect } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../lib/queryClient';
import { setupOnlineManager } from '../lib/onlineManager';
import { Stack } from 'expo-router';

export default function RootLayout() {
  useEffect(() => {
    setupOnlineManager();
  }, []);

  return (
    <QueryClientProvider client={queryClient}>
      <Stack />
    </QueryClientProvider>
  );
}

When the device goes offline, TanStack Query automatically pauses all queries and mutations. When connectivity returns, paused queries refetch and paused mutations retry. You don't have to write any of this logic yourself — which is a huge relief if you've ever tried to build offline support from scratch.

Refetching on App Focus

React Native doesn't have a "window focus" event like the browser does. Instead, you can use the AppState API to refetch data when the app comes back to the foreground:

// hooks/useAppStateRefetch.ts
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { focusManager } from '@tanstack/react-query';

export function useAppStateRefetch() {
  useEffect(() => {
    const subscription = AppState.addEventListener('change', (status) => {
      focusManager.setFocused(status === 'active');
    });

    return () => subscription.remove();
  }, []);
}

Add this hook to your root layout, and TanStack Query will automatically refetch stale queries whenever the user returns to your app from the background. Simple but effective.

Connecting Zustand and TanStack Query

A question that comes up a lot is: how do these two libraries interact? The short answer is that they mostly don't need to — and that's kind of the whole point. Each handles its own domain. But there are a few practical patterns where they complement each other nicely.

Pattern 1: Auth-Dependent Queries

Queries that require authentication should only run when the user is actually logged in:

// hooks/useProfile.ts
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../stores/useAuthStore';
import { api } from '../lib/api';

export function useProfile() {
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);

  return useQuery({
    queryKey: ['profile'],
    queryFn: () => api.get('/me'),
    enabled: isAuthenticated, // Only fetch when logged in
  });
}

Pattern 2: Clearing Cache on Logout

When a user logs out, you need to clear all cached server data. This prevents data from leaking between accounts (something I've actually seen as a bug in production apps):

// hooks/useLogout.ts
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../stores/useAuthStore';

export function useLogout() {
  const queryClient = useQueryClient();
  const logout = useAuthStore((state) => state.logout);

  return () => {
    logout(); // Clear auth state in Zustand
    queryClient.clear(); // Clear all cached queries
  };
}

Pattern 3: Local Filtering of Server Data

Sometimes you want to store a filter or sort preference in Zustand and use it to control which server data to fetch:

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

interface FilterState {
  sortBy: 'newest' | 'oldest' | 'popular';
  category: string | null;
  setSortBy: (sort: 'newest' | 'oldest' | 'popular') => void;
  setCategory: (category: string | null) => void;
}

export const useFilterStore = create<FilterState>((set) => ({
  sortBy: 'newest',
  category: null,
  setSortBy: (sortBy) => set({ sortBy }),
  setCategory: (category) => set({ category }),
}));

// hooks/useFilteredArticles.ts
import { useQuery } from '@tanstack/react-query';
import { useFilterStore } from '../stores/useFilterStore';
import { api } from '../lib/api';

export function useFilteredArticles() {
  const sortBy = useFilterStore((state) => state.sortBy);
  const category = useFilterStore((state) => state.category);

  return useQuery({
    queryKey: ['articles', { sortBy, category }],
    queryFn: () => {
      const params = new URLSearchParams({ sort: sortBy });
      if (category) params.set('category', category);
      return api.get(`/articles?${params}`);
    },
  });
}

When the user changes the sort order or category in the Zustand store, the query key changes, and TanStack Query automatically fetches the new data. If the user switches back to a previous filter combo, the results are served from cache instantly. Pretty slick.

Structuring Your Stores for Scale

As your app grows, you'll want to organize your state management code thoughtfully. Here's a folder structure that I've found scales well:

src/
├── stores/              # Zustand stores (client state)
│   ├── useAppStore.ts   # Theme, onboarding, global UI
│   ├── useAuthStore.ts  # Auth tokens, user session
│   └── useFilterStore.ts # Search filters, sort preferences
├── hooks/               # TanStack Query hooks (server state)
│   ├── useProjects.ts
│   ├── useCreateProject.ts
│   ├── useNotifications.ts
│   ├── useProfile.ts
│   └── useAppStateRefetch.ts
├── lib/                 # Shared utilities
│   ├── api.ts           # Fetch wrapper with auth
│   ├── queryClient.ts   # Query client configuration
│   ├── storage.ts       # MMKV storage adapter
│   └── onlineManager.ts # Network connectivity
└── types/               # Shared TypeScript interfaces
    ├── project.ts
    ├── notification.ts
    └── user.ts

Guidelines for Deciding Where State Goes

When you're adding a new piece of state and aren't sure where it belongs, ask yourself these questions:

  1. Does this data come from an API? → TanStack Query
  2. Is this data owned by the server? → TanStack Query
  3. Is this a user preference that should persist? → Zustand with persist middleware
  4. Is this transient UI state (modal open, form input)? → Local component state with useState
  5. Is this UI state shared across multiple components? → Zustand (without persistence)

Most developers are surprised by how much state can stay in local useState. With TanStack Query handling server data and Zustand managing shared UI state, the amount of truly global client state is usually pretty small.

Performance Considerations

Both Zustand and TanStack Query are designed for performance out of the box, but there are a few React Native-specific things worth keeping in mind.

Avoiding Unnecessary Re-renders

The most common performance mistake with Zustand is subscribing to the entire store:

// BAD: Re-renders on ANY store change
const store = useAppStore();

// GOOD: Re-renders only when theme changes
const theme = useAppStore((state) => state.theme);

For derived state that involves computation, use Zustand's shallow equality check or memoize your selectors:

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

// Re-renders only when the selected values change (shallow comparison)
const { theme, hasCompletedOnboarding } = useAppStore(
  useShallow((state) => ({
    theme: state.theme,
    hasCompletedOnboarding: state.hasCompletedOnboarding,
  }))
);

Query Key Best Practices

TanStack Query uses query keys to identify and cache data. Structure them hierarchically so you can invalidate at exactly the right granularity:

// Hierarchical query keys
const queryKeys = {
  projects: {
    all: ['projects'] as const,
    list: (filters: ProjectFilters) => ['projects', 'list', filters] as const,
    detail: (id: string) => ['projects', 'detail', id] as const,
    members: (id: string) => ['projects', 'detail', id, 'members'] as const,
  },
};

// Invalidate all project-related queries
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });

// Invalidate only the detail for a specific project
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail('123') });

MMKV vs. AsyncStorage Benchmarks

If you're on the fence about whether MMKV is worth the extra setup compared to AsyncStorage, here are some typical benchmarks from a mid-range Android device:

  • Small reads (under 1KB): MMKV is ~30x faster
  • Large reads (10KB+): MMKV is ~10x faster
  • Writes: MMKV is ~25x faster
  • Startup hydration: MMKV restores persisted stores almost instantly; AsyncStorage adds noticeable delay (100-500ms depending on data size)

For auth state that needs to be available immediately on app launch, this difference is very much user-facing. MMKV is the clear winner for any performance-sensitive persistence.

Testing Your State Management

Both Zustand and TanStack Query are designed to be testable, which is a nice change from the testing gymnastics Redux sometimes required. Here are patterns for each.

Testing Zustand Stores

// __tests__/useAuthStore.test.ts
import { useAuthStore } from '../stores/useAuthStore';

// Reset store before each test
beforeEach(() => {
  useAuthStore.setState({
    user: null,
    accessToken: null,
    refreshToken: null,
    isAuthenticated: false,
  });
});

test('setAuth updates all auth fields', () => {
  const user = { id: '1', email: '[email protected]', displayName: 'Test', avatarUrl: null };
  useAuthStore.getState().setAuth(user, 'access-123', 'refresh-456');

  const state = useAuthStore.getState();
  expect(state.isAuthenticated).toBe(true);
  expect(state.user).toEqual(user);
  expect(state.accessToken).toBe('access-123');
});

test('logout clears all auth fields', () => {
  const user = { id: '1', email: '[email protected]', displayName: 'Test', avatarUrl: null };
  useAuthStore.getState().setAuth(user, 'access-123', 'refresh-456');
  useAuthStore.getState().logout();

  const state = useAuthStore.getState();
  expect(state.isAuthenticated).toBe(false);
  expect(state.user).toBeNull();
  expect(state.accessToken).toBeNull();
});

Testing Components with TanStack Query

// __tests__/ProjectsScreen.test.tsx
import { render, screen, waitFor } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ProjectsScreen } from '../screens/ProjectsScreen';

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
    },
  });
}

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = createTestQueryClient();
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

test('renders projects after loading', async () => {
  // Mock the API call
  jest.spyOn(global, 'fetch').mockResolvedValueOnce({
    ok: true,
    json: async () => [
      { id: '1', name: 'My Project', description: 'A test project', createdAt: '2026-01-01', memberCount: 3 },
    ],
  } as Response);

  renderWithProviders(<ProjectsScreen />);

  // Should show loading first
  expect(screen.getByTestId('loading')).toBeTruthy();

  // Then show the project
  await waitFor(() => {
    expect(screen.getByText('My Project')).toBeTruthy();
  });
});

Common Pitfalls and How to Avoid Them

After working with this architecture across several production apps, here are the mistakes I see teams make most often:

1. Storing Server Data in Zustand

This is the big one. The whole point of this setup is to separate client and server state, so if you catch yourself writing setUsers(apiResponse) in a Zustand store, stop. Use a TanStack Query hook instead. It'll handle the caching, refetching, and error states for you.

2. Over-Splitting Zustand Stores

You don't need a separate store for every piece of state. Two or three well-organized stores (app settings, auth, and maybe one more for domain-specific UI state) cover most apps. Creating a store per feature just creates unnecessary fragmentation and makes it harder to reason about your state.

3. Forgetting to Invalidate After Mutations

After a successful mutation, always invalidate the related queries. Otherwise, users see stale data until they manually refresh — which is a terrible experience. Use onSuccess or onSettled in your mutation to trigger invalidation.

4. Not Setting staleTime

The default staleTime in TanStack Query is 0, meaning data is considered stale immediately. For most API endpoints, that's way too aggressive. Set a reasonable staleTime (even 60 seconds makes a difference) to cut down on unnecessary network requests and improve perceived performance.

5. Skipping TypeScript

Both Zustand and TanStack Query have excellent TypeScript support. Define your interfaces, type your query functions, and let the compiler catch mistakes early. The type safety is well worth the extra few lines of interface definitions — trust me, future-you will thank present-you.

Migration Path from Redux

If you're maintaining an existing app with Redux, don't worry — you don't need to rewrite everything at once. Here's a pragmatic migration strategy that actually works:

  1. Start with new features. Write any new feature using Zustand and TanStack Query. Don't touch existing Redux code yet.
  2. Migrate API calls first. Move server state (API data, loading/error states) from Redux to TanStack Query, one endpoint at a time. This gives you the biggest immediate payoff in terms of code reduction.
  3. Move client state last. Once all server state lives in TanStack Query, migrate the remaining client state from Redux to Zustand. This is usually a surprisingly small amount of code.
  4. Remove Redux. Once nothing references the Redux store, remove it and its dependencies. Enjoy the smaller bundle size.

Each step is independently shippable and testable. You can run Redux, Zustand, and TanStack Query side-by-side without conflicts. This incremental approach avoids the risky big-bang rewrite that nobody wants to deal with.

Wrapping Up

The modern React Native state management stack is refreshingly simple compared to what we had before. Zustand gives you a tiny, powerful tool for client state — with optional MMKV persistence for anything that needs to survive app restarts. TanStack Query handles everything server-related — fetching, caching, mutations, optimistic updates, pagination, and offline support — with minimal boilerplate.

Together, they eliminate the need for Redux in most React Native apps. Your code becomes more focused, easier to test, and significantly smaller.

The separation of concerns between client and server state isn't just an architectural nicety — it's a practical improvement that reduces bugs, simplifies debugging, and makes your app faster. Start with the patterns in this guide, adapt them to fit your specific needs, and (honestly) enjoy writing state management code that's actually pleasant to work with.

About the Author Editorial Team

Our team of expert writers and editors.