Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Learning Objectives

By the end of this module, you’ll understand:
  • Local state with useState and useReducer
  • Context API patterns
  • Prop drilling solutions
  • When to use global state
  • How mobile-specific concerns shape state decisions

Why State Management Matters in Mobile

Think of state as the short-term memory of your application. Every piece of data that can change — the text in an input field, whether a modal is open, the list of items fetched from an API — lives in state somewhere. The question is never whether you need state management, but where each piece of state should live. In mobile apps, this decision carries extra weight. Unlike web apps where a page reload clears everything, mobile users expect their app to feel continuous: scroll positions preserved, form inputs remembered, and data cached across navigations. Getting state architecture wrong means janky transitions, lost user input, and excessive network requests that drain battery. The golden rule: state should live as close to where it is used as possible, and only be lifted higher when multiple components genuinely need it.

Local State

useState Pattern

useState is your default tool for any piece of data that belongs to a single component. If only one screen or one widget needs it, keep it local.
import { useState } from 'react';

function Counter() {
  // useState returns a pair: current value and a setter function.
  // The argument (0) is the initial value, only used on first render.
  const [count, setCount] = useState(0);

  return (
    <View>
      <Text>Count: {count}</Text>
      {/* Use the functional updater form (c => c + 1) when the new value
          depends on the previous value. This avoids stale closure bugs,
          which are especially common in mobile event handlers that fire
          rapidly (like gesture callbacks). */}
      <Button title="+" onPress={() => setCount(c => c + 1)} />
    </View>
  );
}
Mobile tip: Every useState setter triggers a re-render. On resource-constrained mobile devices, avoid storing rapidly-changing values (like scroll positions or gesture coordinates) in useState. Use useRef or Reanimated shared values instead — they update without re-rendering.

useReducer for Complex State

When a component has multiple related state values that change together, useReducer keeps the logic organized. Think of it like a state machine: you dispatch an action describing what happened, and the reducer decides how state changes.
import { useReducer } from 'react';

// Define all possible states upfront. This makes it impossible to end up
// in an invalid state like { loading: true, error: 'something' }.
const initialState = { loading: false, data: null, error: null };

function dataReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      // Clear previous errors when starting a new fetch
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, data: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

// Usage in a component
function UserProfile({ userId }) {
  const [state, dispatch] = useReducer(dataReducer, initialState);

  useEffect(() => {
    dispatch({ type: 'FETCH_START' });
    fetchUser(userId)
      .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
      .catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
  }, [userId]);

  if (state.loading) return <ActivityIndicator />;
  if (state.error) return <Text>Error: {state.error}</Text>;
  return <Text>{state.data?.name}</Text>;
}
When to pick useReducer over useState:
  • The next state depends on the previous state in non-trivial ways
  • You have more than 3 related state variables
  • State transitions need to be predictable and testable (the reducer is a pure function you can unit-test in isolation)

Context API

The Problem: Prop Drilling

Imagine you have a theme value at the top of your app and a button 6 components deep that needs it. Without Context, you would have to pass theme as a prop through every intermediate component — even if those components never use it. This is prop drilling, and it makes code brittle and hard to refactor. Context solves this by creating a “tunnel” that lets deeply nested components access shared data directly.

Creating Context

import { createContext, useContext, useState, useMemo } from 'react';

// 1. Create the context with a meaningful default (or null + type guard)
const ThemeContext = createContext(null);

// 2. Build a provider that owns and manages the state
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  // useMemo prevents the value object from being recreated on every render,
  // which would cause every consumer to re-render unnecessarily.
  const value = useMemo(() => ({
    theme,
    isDark: theme === 'dark',
    toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
  }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// 3. Create a custom hook with a helpful error message
export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error(
      'useTheme must be used within a ThemeProvider. ' +
      'Wrap your app root in <ThemeProvider> in App.tsx.'
    );
  }
  return context;
}
Context re-render pitfall: When a Context value changes, every component consuming that context re-renders — even if it only uses a property that did not change. For high-frequency updates (like animation values or cursor positions), Context is the wrong tool. Use a dedicated state library like Zustand or Jotai instead, which support selective subscriptions.

When to Use Context vs. a State Library

ScenarioUse ContextUse a Library (Zustand, etc.)
Theme / locale / auth statusYes — changes rarelyAlso works, but overkill
Shopping cartMaybe — if small appYes — many consumers, frequent updates
Real-time data (chat, presence)No — too many re-rendersYes — selective subscriptions
Form state across screensNoYes — or React Hook Form

Derived State: Calculate, Don’t Store

One of the most common state management mistakes is storing values that could be calculated from existing state. Every piece of redundant state is a synchronization bug waiting to happen.
// Bad: storing both items and filteredItems
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]); // Sync risk!
const [searchQuery, setSearchQuery] = useState('');

// Good: derive filteredItems from items + searchQuery
const [items, setItems] = useState([]);
const [searchQuery, setSearchQuery] = useState('');

// useMemo recalculates only when items or searchQuery change
const filteredItems = useMemo(
  () => items.filter(item =>
    item.name.toLowerCase().includes(searchQuery.toLowerCase())
  ),
  [items, searchQuery]
);

Best Practices

  1. Start local, lift when needed — Do not reach for global state until two or more unrelated components need the same data.
  2. Use context for truly global, rarely-changing state — Theme, locale, and authentication status are ideal. Shopping carts and real-time feeds are not.
  3. Avoid prop drilling with composition — Before adding Context, try restructuring your component tree so that the parent renders the consuming child directly (the “slots” pattern).
  4. Keep state minimal — Derive computed values with useMemo rather than storing them separately. Two sources of truth will eventually disagree.
  5. Profile before optimizing — On mobile, unnecessary re-renders matter more than on web, but the React Profiler should guide your decisions, not assumptions.