State Management

This boilerplate uses a hybrid approach combining Zustand for global state and React Query for server state, providing a robust and scalable solution.

State Categories

🌍 Global State (Zustand)

User authentication, app settings, persistent UI state

🔄 Server State (React Query)

API data, caching, synchronization, background refetch

📝 Local State (useState)

Form inputs, modals, component-specific UI state

🎛️ URL State (Expo Router)

Navigation state, search params, deep linking

Zustand - Global State

Zustand is a minimal, fast state management library perfect for React Native.

Creating a Store

src/stores/auth.store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { storage } from '@/data/storage/mmkv';
import type { User } from '@/domain/entities/user';

interface AuthState {
  // State
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  
  // Actions
  login: (token: string, user: User) => void;
  logout: () => void;
  updateUser: (user: Partial<User>) => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      // Initial state
      user: null,
      token: null,
      isAuthenticated: false,

      // Actions
      login: (token, user) => set({ 
        token, 
        user, 
        isAuthenticated: true 
      }),

      logout: () => set({ 
        token: null, 
        user: null, 
        isAuthenticated: false 
      }),

      updateUser: (updates) => set((state) => ({
        user: state.user ? { ...state.user, ...updates } : null
      })),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => ({
        getItem: (name) => storage.getString(name) ?? null,
        setItem: (name, value) => storage.set(name, value),
        removeItem: (name) => storage.delete(name),
      })),
    }
  )
);

Using a Store

typescript
// In a component or ViewModel
import { useAuthStore } from '@/stores/auth.store';

export function useProfileViewModel() {
  // Select specific state (prevents unnecessary re-renders)
  const user = useAuthStore(state => state.user);
  const isAuthenticated = useAuthStore(state => state.isAuthenticated);
  
  // Select actions
  const logout = useAuthStore(state => state.logout);
  const updateUser = useAuthStore(state => state.updateUser);

  const handleLogout = () => {
    logout();
    router.replace('/login');
  };

  return { user, isAuthenticated, handleLogout, updateUser };
}
Always select specific properties from stores to optimize re-renders. Don't use const state = useAuthStore() - this subscribes to all changes!

Store Best Practices

✅ Keep stores focused

One store per domain: auth.store, settings.store, cart.store

✅ Use selectors

Select only the state you need to prevent unnecessary re-renders

✅ Include actions in store

Keep state and actions together for better organization

React Query - Server State

React Query handles all server state: fetching, caching, synchronization, and updates.

Setup

src/app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      staleTime: 5 * 60 * 1000, // 5 minutes
      gcTime: 10 * 60 * 1000,   // 10 minutes
    },
  },
});

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

Queries - Fetching Data

typescript
import { useQuery } from '@tanstack/react-query';
import { taskApi } from '@/data/api/endpoints/tasks';

export function useTaskListViewModel() {
  const { 
    data, 
    isLoading, 
    error, 
    refetch 
  } = useQuery({
    queryKey: ['tasks'],
    queryFn: taskApi.getTasks,
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
  });

  return {
    tasks: data ?? [],
    isLoading,
    error,
    refetch,
  };
}

Mutations - Updating Data

typescript
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { taskApi } from '@/data/api/endpoints/tasks';

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

  const createMutation = useMutation({
    mutationFn: taskApi.createTask,
    onSuccess: () => {
      // Refetch tasks list
      queryClient.invalidateQueries(['tasks']);
    },
  });

  const updateMutation = useMutation({
    mutationFn: taskApi.updateTask,
    onMutate: async (updatedTask) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries(['tasks']);

      // Optimistic update
      const previousTasks = queryClient.getQueryData(['tasks']);
      queryClient.setQueryData(['tasks'], (old: any[]) =>
        old.map(t => t.id === updatedTask.id ? updatedTask : t)
      );

      return { previousTasks };
    },
    onError: (err, variables, context) => {
      // Rollback on error
      queryClient.setQueryData(['tasks'], context?.previousTasks);
    },
  });

  return {
    createTask: createMutation.mutate,
    updateTask: updateMutation.mutate,
    isCreating: createMutation.isPending,
    isUpdating: updateMutation.isPending,
  };
}

Pagination & Infinite Queries

typescript
import { useInfiniteQuery } from '@tanstack/react-query';

export function useInfinitePostsViewModel() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => 
      postApi.getPosts({ page: pageParam, limit: 20 }),
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 1,
  });

  const posts = data?.pages.flatMap(page => page.data) ?? [];

  return {
    posts,
    loadMore: fetchNextPage,
    hasMore: hasNextPage,
    isLoadingMore: isFetchingNextPage,
  };
}

Local Component State

Use React's built-in hooks for component-local state:

typescript
import { useState, useCallback } from 'react';

export function useFilterViewModel() {
  const [searchQuery, setSearchQuery] = useState('');
  const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
  const [isFilterOpen, setIsFilterOpen] = useState(false);

  const handleSearch = useCallback((query: string) => {
    setSearchQuery(query);
  }, []);

  const handleSelectCategory = useCallback((category: string) => {
    setSelectedCategory(category);
  }, []);

  const toggleFilter = useCallback(() => {
    setIsFilterOpen(prev => !prev);
  }, []);

  const clearFilters = useCallback(() => {
    setSearchQuery('');
    setSelectedCategory(null);
  }, []);

  return {
    searchQuery,
    selectedCategory,
    isFilterOpen,
    handleSearch,
    handleSelectCategory,
    toggleFilter,
    clearFilters,
  };
}

State Flow Diagram

User Interaction (View)
        │
        ▼
   ViewModel Hook
        │
        ├─────► useState ────► Local Component State
        │                      (form inputs, UI toggles)
        │
        ├─────► useQuery ────► React Query Cache
        │                      (server data, auto-refetch)
        │
        └─────► useStore ────► Zustand Store
                               (global app state)
                                      │
                                      ▼
                               MMKV Storage
                               (persistence)

When to Use What?

Use CaseSolution
User authenticationZustand (persisted)
App theme/languageZustand (persisted)
Fetching API dataReact Query
Form input valuesuseState or React Hook Form
Modal open/closeduseState
Shopping cartZustand (persisted)
Current screenExpo Router (URL state)

Testing State

Testing Zustand Stores

typescript
import { act, renderHook } from '@testing-library/react-hooks';
import { useAuthStore } from '@/stores/auth.store';

describe('useAuthStore', () => {
  beforeEach(() => {
    const { result } = renderHook(() => useAuthStore());
    act(() => result.current.logout()); // Reset store
  });

  it('logs in user', () => {
    const { result } = renderHook(() => useAuthStore());
    
    act(() => {
      result.current.login('token123', {
        id: '1',
        name: 'John Doe',
      });
    });

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user?.name).toBe('John Doe');
  });
});

Testing React Query

typescript
import { renderHook, waitFor } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useTaskListViewModel } from './useTaskListViewModel';

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

const wrapper = ({ children }) => (
  <QueryClientProvider client={queryClient}>
    {children}
  </QueryClientProvider>
);

it('fetches tasks', async () => {
  const { result } = renderHook(() => useTaskListViewModel(), { wrapper });

  expect(result.current.isLoading).toBe(true);

  await waitFor(() => expect(result.current.isLoading).toBe(false));

  expect(result.current.tasks).toHaveLength(3);
});
This hybrid approach gives you the best of both worlds: reactive global state with Zustand and powerful server state management with React Query.

Next Steps