Skip to main content

State Management Overview

State management is about organizing and coordinating data across your application. The right approach depends on your app’s complexity and requirements.

Local State

Component-specific state with useState and useReducer

Global State

App-wide state with Context API or state libraries

Server State

Remote data with caching and synchronization

URL State

State reflected in URL parameters and routes

Types of State

Local Component State

State that belongs to a single component and doesn’t need to be shared.When to use:
  • Form input values
  • UI toggles (modals, dropdowns)
  • Component-specific temporary data
function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: '',
  });
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});
  
  const handleChange = (field: string, value: string) => {
    setFormData(prev => ({ ...prev, [field]: value }));
    // Clear error when user starts typing
    if (errors[field]) {
      setErrors(prev => {
        const next = { ...prev };
        delete next[field];
        return next;
      });
    }
  };
  
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    
    // Validate
    const validationErrors = validateForm(formData);
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    
    // Submit
    setIsSubmitting(true);
    try {
      await submitContactForm(formData);
      // Reset form on success
      setFormData({ name: '', email: '', message: '' });
      toast.success('Message sent!');
    } catch (error) {
      toast.error('Failed to send message');
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <Input
        label="Name"
        value={formData.name}
        onChange={(value) => handleChange('name', value)}
        error={errors.name}
      />
      <Input
        label="Email"
        type="email"
        value={formData.email}
        onChange={(value) => handleChange('email', value)}
        error={errors.email}
      />
      <TextArea
        label="Message"
        value={formData.message}
        onChange={(value) => handleChange('message', value)}
        error={errors.message}
      />
      <Button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send Message'}
      </Button>
    </form>
  );
}
Keep state as local as possible. Only lift state up when multiple components need access to it.

Data Flow Patterns

Unidirectional Data Flow

Data flows down through props, events flow up through callbacks.
// Top-level state
function App() {
  const [todos, setTodos] = useState<Todo[]>([]);
  
  const addTodo = (text: string) => {
    const newTodo = { id: generateId(), text, completed: false };
    setTodos(prev => [...prev, newTodo]);
  };
  
  const toggleTodo = (id: string) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };
  
  const deleteTodo = (id: string) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };
  
  // Data flows down, events flow up
  return (
    <div>
      <TodoInput onAdd={addTodo} />
      <TodoList
        todos={todos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
    </div>
  );
}

// Child receives data and callbacks
function TodoList({ todos, onToggle, onDelete }: TodoListProps) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={() => onToggle(todo.id)}
          onDelete={() => onDelete(todo.id)}
        />
      ))}
    </ul>
  );
}
Unidirectional data flow makes your app predictable and easier to debug. Data changes always originate from a single source.

State Management Libraries

Zustand - Simple and Flexible

import { create } from 'zustand';

interface CartStore {
  items: CartItem[];
  addItem: (product: Product, quantity: number) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  total: number;
}

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

// Usage in components
function Cart() {
  const items = useCartStore(state => state.items);
  const total = useCartStore(state => state.total);
  const removeItem = useCartStore(state => state.removeItem);
  
  return (
    <div className="cart">
      {items.map(item => (
        <CartItem
          key={item.product.id}
          item={item}
          onRemove={() => removeItem(item.product.id)}
        />
      ))}
      <div className="cart-total">Total: ${total}</div>
    </div>
  );
}

Redux Toolkit - Powerful and Opinionated

import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';

interface TodosState {
  items: Todo[];
  filter: 'all' | 'active' | 'completed';
}

const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    filter: 'all',
  } as TodosState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      state.items.push({
        id: generateId(),
        text: action.payload,
        completed: false,
      });
    },
    toggleTodo: (state, action: PayloadAction<string>) => {
      const todo = state.items.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
      state.filter = action.payload;
    },
  },
});

export const { addTodo, toggleTodo, setFilter } = todosSlice.actions;

const store = configureStore({
  reducer: {
    todos: todosSlice.reducer,
  },
});

// Usage with hooks
function TodoApp() {
  const dispatch = useDispatch();
  const todos = useSelector((state: RootState) => state.todos.items);
  const filter = useSelector((state: RootState) => state.todos.filter);
  
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });
  
  return (
    <div>
      <FilterButtons
        current={filter}
        onChange={(f) => dispatch(setFilter(f))}
      />
      <TodoList
        todos={filteredTodos}
        onToggle={(id) => dispatch(toggleTodo(id))}
      />
    </div>
  );
}

Best Practices

  • Start with local state
  • Lift state only when necessary
  • Use context for truly global data
  • Use server state libraries for API data
Don’t put everything in global state. Most state should be local or server state.
// Bad: Nested duplication
const state = {
  posts: [
    { id: '1', title: 'Post 1', author: { id: 'a', name: 'Alice' } },
    { id: '2', title: 'Post 2', author: { id: 'a', name: 'Alice' } },
  ],
};

// Good: Normalized with references
const state = {
  posts: {
    byId: {
      '1': { id: '1', title: 'Post 1', authorId: 'a' },
      '2': { id: '2', title: 'Post 2', authorId: 'a' },
    },
    allIds: ['1', '2'],
  },
  users: {
    byId: {
      'a': { id: 'a', name: 'Alice' },
    },
  },
};
// Don't store computed values
function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  
  // Bad: Storing derived state
  // const [completedCount, setCompletedCount] = useState(0);
  
  // Good: Computing on demand
  const completedCount = todos.filter(t => t.completed).length;
  const activeCount = todos.length - completedCount;
  
  return (
    <div>
      <p>{activeCount} active</p>
      <p>{completedCount} completed</p>
    </div>
  );
}
// React 18+ automatically batches updates
function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // Only one re-render!
}

// For async batching, use startTransition
function handleAsync() {
  startTransition(() => {
    setQuery(value);
    setResults(data);
    setLoading(false);
  });
}
State management is about trade-offs. Choose simplicity over complexity until you have a specific need for more sophisticated patterns.

Build docs developers (and LLMs) love