Back to all posts
December 25, 2025Charlie BrownDevelopment

Expo React Native State Management: Modern Patterns and Best Practices

Learn effective state management patterns for Expo React Native apps using Context API, Zustand, and React Query for scalable applications.

Expo React Native State Management: Modern Patterns and Best Practices

Expo React Native State Management: Modern Patterns and Best Practices

State management is crucial for building scalable React Native applications. With Expo, you have access to modern React patterns and libraries that make state management efficient and maintainable. In this article, we'll explore different state management approaches for Expo React Native applications, from simple Context API to advanced patterns with Zustand and React Query.

Understanding State Management Needs

Before choosing a state management solution, understand your application's needs:

  • Local State: Component-specific state (useState)
  • Shared State: State shared across components (Context API, Zustand)
  • Server State: Data fetched from APIs (React Query, SWR)
  • Form State: Form inputs and validation (React Hook Form)
  • Persistent State: State that survives app restarts (AsyncStorage, MMKV)

Context API for Simple Shared State

1. Basic Context Setup

For simple shared state, Context API works well:

typescript
// contexts/AuthContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface User {
  id: string;
  email: string;
  name: string;
}

interface AuthContextType {
  user: User | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    loadStoredUser();
  }, []);

  async function loadStoredUser() {
    try {
      const storedUser = await AsyncStorage.getItem('user');
      if (storedUser) {
        setUser(JSON.parse(storedUser));
      }
    } catch (error) {
      console.error('Error loading user:', error);
    } finally {
      setIsLoading(false);
    }
  }

  async function login(email: string, password: string) {
    // API call
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    const userData = await response.json();
    setUser(userData);
    await AsyncStorage.setItem('user', JSON.stringify(userData));
  }

  async function logout() {
    setUser(null);
    await AsyncStorage.removeItem('user');
  }

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

2. Using Context in Components

typescript
// screens/ProfileScreen.tsx
import React from 'react';
import { View, Text, Button } from 'react-native';
import { useAuth } from '../contexts/AuthContext';

export function ProfileScreen() {
  const { user, logout } = useAuth();

  if (!user) {
    return <Text>Not logged in</Text>;
  }

  return (
    <View>
      <Text>Welcome, {user.name}!</Text>
      <Text>Email: {user.email}</Text>
      <Button title="Logout" onPress={logout} />
    </View>
  );
}

Zustand for Global State

Zustand provides a lightweight, scalable solution for global state:

1. Zustand Store Setup

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

interface User {
  id: string;
  email: string;
  name: string;
}

interface UserState {
  user: User | null;
  setUser: (user: User | null) => void;
  clearUser: () => void;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      user: null,
      setUser: (user) => set({ user }),
      clearUser: () => set({ user: null }),
    }),
    {
      name: 'user-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

2. Complex Store with Actions

typescript
// stores/cartStore.ts
import create from 'zustand';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  total: () => number;
}

export const useCartStore = create<CartState>((set, get) => ({
  items: [],
  
  addItem: (item) => {
    const existingItem = get().items.find((i) => i.id === item.id);
    
    if (existingItem) {
      set((state) => ({
        items: state.items.map((i) =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        ),
      }));
    } else {
      set((state) => ({
        items: [...state.items, { ...item, quantity: 1 }],
      }));
    }
  },
  
  removeItem: (id) => {
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    }));
  },
  
  updateQuantity: (id, quantity) => {
    if (quantity <= 0) {
      get().removeItem(id);
      return;
    }
    
    set((state) => ({
      items: state.items.map((item) =>
        item.id === id ? { ...item, quantity } : item
      ),
    }));
  },
  
  clearCart: () => {
    set({ items: [] });
  },
  
  total: () => {
    return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  },
}));

3. Using Zustand in Components

typescript
// components/Cart.tsx
import React from 'react';
import { View, Text, FlatList, Button } from 'react-native';
import { useCartStore } from '../stores/cartStore';

export function Cart() {
  const items = useCartStore((state) => state.items);
  const updateQuantity = useCartStore((state) => state.updateQuantity);
  const total = useCartStore((state) => state.total());

  return (
    <View>
      <FlatList
        data={items}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <View>
            <Text>{item.name}</Text>
            <Text>${item.price} x {item.quantity}</Text>
            <Button
              title="-"
              onPress={() => updateQuantity(item.id, item.quantity - 1)}
            />
            <Button
              title="+"
              onPress={() => updateQuantity(item.id, item.quantity + 1)}
            />
          </View>
        )}
      />
      <Text>Total: ${total}</Text>
    </View>
  );
}

React Query for Server State

React Query handles server state, caching, and synchronization:

1. React Query Setup

typescript
// App.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

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

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your app */}
    </QueryClientProvider>
  );
}

2. Custom Hooks for API Calls

typescript
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

interface User {
  id: string;
  name: string;
  email: string;
}

async function fetchUsers(): Promise<User[]> {
  const response = await fetch('/api/users');
  if (!response.ok) {
    throw new Error('Failed to fetch users');
  }
  return response.json();
}

async function createUser(user: Omit<User, 'id'>): Promise<User> {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(user),
  });
  if (!response.ok) {
    throw new Error('Failed to create user');
  }
  return response.json();
}

export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });
}

export function useCreateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // Invalidate and refetch users
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

3. Using React Query in Components

typescript
// screens/UsersScreen.tsx
import React from 'react';
import { View, Text, FlatList, ActivityIndicator, Button } from 'react-native';
import { useUsers, useCreateUser } from '../hooks/useUsers';

export function UsersScreen() {
  const { data: users, isLoading, error } = useUsers();
  const createUserMutation = useCreateUser();

  if (isLoading) {
    return <ActivityIndicator />;
  }

  if (error) {
    return <Text>Error: {error.message}</Text>;
  }

  const handleCreateUser = () => {
    createUserMutation.mutate({
      name: 'New User',
      email: 'user@example.com',
    });
  };

  return (
    <View>
      <Button
        title="Add User"
        onPress={handleCreateUser}
        disabled={createUserMutation.isPending}
      />
      <FlatList
        data={users}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <View>
            <Text>{item.name}</Text>
            <Text>{item.email}</Text>
          </View>
        )}
      />
    </View>
  );
}

Combining State Management Solutions

1. Hybrid Approach

Combine different solutions for different needs:

typescript
// App.tsx
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/AuthContext';

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        {/* Your app */}
      </AuthProvider>
    </QueryClientProvider>
  );
}

2. Form State with React Hook Form

typescript
// components/LoginForm.tsx
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { TextInput, Button, View, Text } from 'react-native';
import { useAuth } from '../contexts/AuthContext';

interface LoginFormData {
  email: string;
  password: string;
}

export function LoginForm() {
  const { login } = useAuth();
  const { control, handleSubmit, formState: { errors } } = useForm<LoginFormData>();

  const onSubmit = async (data: LoginFormData) => {
    await login(data.email, data.password);
  };

  return (
    <View>
      <Controller
        control={control}
        name="email"
        rules={{
          required: 'Email is required',
          pattern: {
            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
            message: 'Invalid email address',
          },
        }}
        render={({ field: { onChange, value } }) => (
          <TextInput
            value={value}
            onChangeText={onChange}
            placeholder="Email"
            keyboardType="email-address"
            autoCapitalize="none"
          />
        )}
      />
      {errors.email && <Text>{errors.email.message}</Text>}

      <Controller
        control={control}
        name="password"
        rules={{
          required: 'Password is required',
          minLength: {
            value: 8,
            message: 'Password must be at least 8 characters',
          },
        }}
        render={({ field: { onChange, value } }) => (
          <TextInput
            value={value}
            onChangeText={onChange}
            placeholder="Password"
            secureTextEntry
          />
        )}
      />
      {errors.password && <Text>{errors.password.message}</Text>}

      <Button title="Login" onPress={handleSubmit(onSubmit)} />
    </View>
  );
}

Performance Optimization

1. Memoization

Use React.memo and useMemo for expensive computations:

typescript
// components/ExpensiveComponent.tsx
import React, { useMemo } from 'react';
import { View, Text } from 'react-native';

interface ExpensiveComponentProps {
  items: number[];
}

export const ExpensiveComponent = React.memo(({ items }: ExpensiveComponentProps) => {
  const expensiveValue = useMemo(() => {
    return items.reduce((sum, item) => sum + item * 2, 0);
  }, [items]);

  return (
    <View>
      <Text>Result: {expensiveValue}</Text>
    </View>
  );
});

2. Selective Subscriptions

With Zustand, subscribe only to needed state:

typescript
// ✅ Good: Subscribe only to needed state
const userName = useUserStore((state) => state.user?.name);

// ❌ Bad: Subscribe to entire store
const { user } = useUserStore();

Best Practices Summary

  1. Use useState for local component state
  2. Use Context API for simple shared state
  3. Use Zustand for complex global state
  4. Use React Query for server state and caching
  5. Use React Hook Form for form state
  6. Persist important state with AsyncStorage or MMKV
  7. Memoize expensive computations with useMemo
  8. Selectively subscribe to state changes
  9. Combine solutions for different needs
  10. Keep state normalized and avoid duplication

Conclusion

Effective state management in Expo React Native requires choosing the right tool for each use case. Context API works for simple shared state, Zustand excels for complex global state, and React Query handles server state beautifully. By combining these tools appropriately and following best practices, you can build scalable, maintainable React Native applications.

References

Want more insights?

Subscribe to our newsletter or follow us for more updates on software development and team scaling.

Contact Us