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 Case | Solution |
|---|---|
| User authentication | Zustand (persisted) |
| App theme/language | Zustand (persisted) |
| Fetching API data | React Query |
| Form input values | useState or React Hook Form |
| Modal open/closed | useState |
| Shopping cart | Zustand (persisted) |
| Current screen | Expo 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.