State Management and Backend API Integration

When your app has only two or three pages, you can use React's built-in useState and props to pass data. But as the app grows larger:

  • The "Home" page needs to display the user's avatar.
  • The "Profile" page allows modifying the avatar.
  • The "Sidebar" also needs to display the avatar.

If you still rely on passing props layer by layer, you'll encounter the infamous "Props Drilling" problem. This is when we need Global State Management.

1. Ditch Redux, Embrace Zustand!

In the past, Redux was the go-to choice for React developers. However, Redux setup is extremely cumbersomeโ€”you need to write Actions, Reducers, and Dispatches, which is overkill for small projects.

In recent years, Zustand (German for "state") has become the favorite of modern React/RN developers. It's extremely lightweight, doesn't require wrapping the entire app in <Provider>, and its syntax is as simple as using a regular Hook!

Install Zustand:

npm install zustand

Create a Store

Create a store/useAuthStore.ts in your project:

import { create } from 'zustand';

// 1. Define types
interface AuthState {
  user: { name: string; avatar: string } | null;
  isAuthenticated: boolean;
  login: (userData: { name: string; avatar: string }) => void;
  logout: () => void;
}

// 2. Create Store
export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  isAuthenticated: false,
  
  // Define state-modifying methods (Actions)
  login: (userData) => set({ user: userData, isAuthenticated: true }),
  logout: () => set({ user: null, isAuthenticated: false }),
}));

Use the Store Across Different Pages

It's that simple! Now you can read or modify this state anywhere in your app:

In Page A (Login Screen):

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

export default function LoginScreen() {
  // Only destructure the login function to avoid unnecessary re-renders
  const login = useAuthStore((state) => state.login);

  const handleLogin = () => {
    // Simulate API success
    login({ name: "Ken", avatar: "https://example.com/avatar.jpg" });
  };

  return <Button title="Login" onPress={handleLogin} />;
}

In Page B (Homepage Header):

import { useAuthStore } from '../store/useAuthStore';
import { Text, Image } from 'react-native';

export default function Header() {
  const user = useAuthStore((state) => state.user);

  if (!user) return <Text>Please log in</Text>;

  return (
    <View>
      <Image source={{ uri: user.avatar }} style={{ width: 40, height: 40 }} />
      <Text>Welcome back, {user.name}!</Text>
    </View>
  );
}

2. API Data Fetching Best Practices: React Query (TanStack Query)

Zustand is perfect for managing "local UI state" (e.g., whether the sidebar is open, dark/light mode).
But for "data fetched from backend servers" (e.g., product lists, post lists), we strongly recommend using React Query!

In mobile apps, network conditions are highly unstable (users might lose connection in a tunnel). If we only use plain fetch or axios, we'd have to manually handle loading states, error states, refetching, and offline caching... which is a nightmare.

React Query is here to save you.

Installation:

npm install @tanstack/react-query

Implement an Infinite Scroll Product List

import { ActivityIndicator, FlatList, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';

// Define an API-fetching function
const fetchProducts = async () => {
  const response = await fetch('https://fakestoreapi.com/products');
  if (!response.ok) throw new Error('Network error');
  return response.json();
};

export default function ProductList() {
  // React Query's magical Hook
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['products'], // This key is used for cache management
    queryFn: fetchProducts,
  });

  // 1. Automatically handles loading UI
  if (isLoading) return <ActivityIndicator size="large" />;

  // 2. Automatically handles error UI
  if (isError) return <Text>Error: {error.message}</Text>;

  // 3. Render the list with built-in "Pull-to-refresh" functionality
  return (
    <FlatList 
      data={data}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => (
        <View style={{ padding: 16, borderBottomWidth: 1 }}>
          <Text style={{ fontSize: 18 }}>{item.title}</Text>
          <Text style={{ color: 'green' }}>${item.price}</Text>
        </View>
      )}
      // Just these two lines enable pull-to-refresh!
      refreshing={isLoading}
      onRefresh={refetch} 
    />
  );
}

[!TIP] FlatList Performance Trick
On the web, if you need to display 1000 items, you might use data.map() to render them all. But never do this on mobile! It will crash due to memory overload.
You must use React Native's built-in <FlatList>. It's a virtualized list that only renders items "currently visible on screen." As you scroll, it recycles memory from off-screen items for upcoming data.

With Zustand and React Query, your app's core architecture has reached industry-leading standards.
In the final chapter, we'll tackle the most daunting challenge in app developmentโ€”building and publishing to both platform stores!

Zustand Store Patterns

Store with Actions

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

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

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      isLoading: false,
      error: null,

      login: async (email, password) => {
        set({ isLoading: true, error: null });
        try {
          const response = await api.post('/auth/login', { email, password });
          set({
            user: response.data.user,
            token: response.data.token,
            isLoading: false
          });
        } catch (error) {
          set({ error: error.message, isLoading: false });
        }
      },

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

      setUser: (user) => set({ user })
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage)
    }
  )
);

Derived State with Selectors

// Optimized selectors prevent unnecessary re-renders
const userName = useAuthStore((state) => state.user?.name);
const isAuthenticated = useAuthStore((state) => !!state.token);
const isLoading = useAuthStore((state) => state.isLoading);

// Component only re-renders when its specific selector changes
function UserGreeting() {
  const name = useAuthStore((state) => state.user?.name);
  return <Text>Hello, {name || 'Guest'}!</Text>;
}

function LoginButton() {
  const isAuth = useAuthStore((state) => !!state.token);
  const logout = useAuthStore((state) => state.logout);
  
  if (!isAuth) return null;
  return <Button title="Logout" onPress={logout} />;
}

React Query for Server State

Setup Provider

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,     // 5 min before refetch
      gcTime: 30 * 60 * 1000,         // 30 min in cache
      retry: 2,
      refetchOnWindowFocus: false,    // Not relevant in mobile
    },
  },
});

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

Using React Query

import { useQuery, useMutation } from '@tanstack/react-query';

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => api.get('/products').then(res => res.data),
  });
}

function useCreateProduct() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (product: Product) => api.post('/products', product),
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });
}

function ProductList() {
  const { data, isLoading, error } = useProducts();
  
  if (isLoading) return <ActivityIndicator />;
  if (error) return <Text>Error: {error.message}</Text>;
  
  return (
    <FlatList
      data={data}
      renderItem={({ item }) => <ProductCard product={item} />}
      keyExtractor={(item) => item.id.toString()}
    />
  );
}

State Management Comparison

| Solution | Type | Best For | Persistence | |----------|------|----------|-------------| | React State (useState) | Local | Simple UI state | Manual | | Context API | Shared | Low-frequency, small state | Manual | | Zustand | Global | Complex app state | Via middleware | | React Query | Server | API data, caching | Automatic | | MMKV | Persistent | Fast key-value storage | Built-in | | Redux Toolkit | Global | Large-scale apps | Via middleware |

AsyncStorage vs MMKV

| Feature | AsyncStorage | MMKV | |---------|--------------|------| | Speed | Slower (JS bridge) | 30ร— faster (native) | | Sync/Async | Async only | Both | | Size Limit | ~6 MB on Android | Large (500 MB+) | | Setup | Built-in with expo | Extra package |

# MMKV installation
npx expo install react-native-mmkv
import { MMKV } from 'react-native-mmkv';

export const storage = new MMKV({
  id: 'my-app-storage',
  encryptionKey: 'my-secret-key'
});

// Store
storage.set('user.name', 'Alice');
storage.set('user.age', 30);
storage.set('is_logged_in', true);

// Retrieve
const name = storage.getString('user.name');
const age = storage.getNumber('user.age');
const loggedIn = storage.getBoolean('is_logged_in');

Summary

State management in React Native uses Zustand for global state, React Query for server state, and MMKV for persistence. Each tool serves a specific purpose.

Key takeaways:

  • Zustand: lightweight global store with selectors and persistence |
  • React Query: server state with caching, refetching, and mutations |
  • Zustand selectors prevent unnecessary component re-renders |
  • Persist Zustand stores with AsyncStorage or MMKV middleware |
  • MMKV is 30ร— faster than AsyncStorage for mobile |
  • React Query auto-manages loading, error, and success states |
  • Use mutations for writes, queries for reads |
  • Invalid queries on mutation success to refresh data |

What's Next: App Store Deployment

The next chapter covers building and publishing to app stores.

Unlock Full Tutorial

This chapter is paid content. Join the project to unlock over 5000 words of deep analysis, including 10+ god-tier Prompts and real Source Code examples!