React
03 / 07

State Management

React State Management

Comprehensive guide to managing state in React applications, from simple local state to complex global state management:

Local State with useState

useState is the foundation of React state management. It's perfect for component-level state that doesn't need to be shared.

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  
  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
      
      <input 
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Enter your name"
      />
      <p>Hello, {name || 'Anonymous'}!</p>
    </div>
  );
}

Complex Local State with useReducer

useReducer is ideal for managing complex state logic with multiple sub-values or when the next state depends on the previous one.

const initialState = {
  todos: [],
  filter: 'all',
  loading: false,
  error: null
};

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.text,
          completed: false
        }]
      };
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    
    case 'SET_FILTER':
      return { ...state, filter: action.filter };
    
    case 'SET_LOADING':
      return { ...state, loading: action.loading };
    
    case 'SET_ERROR':
      return { ...state, error: action.error };
    
    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  
  const addTodo = (text) => {
    dispatch({ type: 'ADD_TODO', text });
  };
  
  const toggleTodo = (id) => {
    dispatch({ type: 'TOGGLE_TODO', id });
  };
  
  const setFilter = (filter) => {
    dispatch({ type: 'SET_FILTER', filter });
  };
  
  return (
    <div>
      <TodoForm onAdd={addTodo} />
      <TodoFilter filter={state.filter} onFilterChange={setFilter} />
      <TodoList 
        todos={state.todos}
        filter={state.filter}
        onToggle={toggleTodo}
      />
    </div>
  );
}

Context API for Global State

The Context API provides a way to pass data through the component tree without having to pass props down manually at every level.

// Create context
const ThemeContext = createContext();
const UserContext = createContext();

// Theme provider
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };
  
  const value = {
    theme,
    toggleTheme,
    colors: theme === 'light' ? lightColors : darkColors
  };
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// User provider
function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    // Load user from API
    loadUser().then(userData => {
      setUser(userData);
      setLoading(false);
    });
  }, []);
  
  const login = async (credentials) => {
    const userData = await authenticate(credentials);
    setUser(userData);
  };
  
  const logout = () => {
    setUser(null);
  };
  
  return (
    <UserContext.Provider value={{ user, login, logout, loading }}>
      {children}
    </UserContext.Provider>
  );
}

// Custom hooks for consuming context
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
}

// Usage in components
function App() {
  return (
    <ThemeProvider>
      <UserProvider>
        <Header />
        <MainContent />
        <Footer />
      </UserProvider>
    </ThemeProvider>
  );
}

function Header() {
  const { theme, toggleTheme } = useTheme();
  const { user, logout } = useUser();
  
  return (
    <header className={`header-${theme}`}>
      <h1>My App</h1>
      <button onClick={toggleTheme}>
        Switch to {theme === 'light' ? 'dark' : 'light'} mode
      </button>
      {user && (
        <div>
          <span>Welcome, {user.name}!</span>
          <button onClick={logout}>Logout</button>
        </div>
      )}
    </header>
  );
}

Redux - Predictable State Container

Redux is a predictable state container for JavaScript apps. It helps you write applications that behave consistently and are easy to test.

// Store setup
import { createStore, combineReducers, applyMiddleware } from 'redux';
import { createSlice, configureStore } from '@reduxjs/toolkit';

// Redux Toolkit approach (recommended)
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    addTodo: (state, action) => {
      state.items.push({
        id: Date.now(),
        text: action.payload,
        completed: false
      });
    },
    toggleTodo: (state, action) => {
      const todo = state.items.find(item => item.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
  },
});

// Configure store
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    todos: todosSlice.reducer,
  },
});

// React component with Redux
import { useSelector, useDispatch } from 'react-redux';

function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(counterSlice.actions.increment())}>
        +
      </button>
      <button onClick={() => dispatch(counterSlice.actions.decrement())}>
        -
      </button>
    </div>
  );
}

Zustand - Lightweight State Management

Zustand is a small, fast and scalable state management solution with a comfy API based on hooks and isn't boilerplate-heavy.

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

// Simple store
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

// Complex store with middleware
const useTodoStore = create(
  devtools(
    persist(
      (set, get) => ({
        todos: [],
        filter: 'all',
        
        addTodo: (text) =>
          set(
            (state) => ({
              todos: [
                ...state.todos,
                {
                  id: Date.now(),
                  text,
                  completed: false,
                  createdAt: new Date(),
                },
              ],
            }),
            false,
            'addTodo'
          ),
        
        toggleTodo: (id) =>
          set(
            (state) => ({
              todos: state.todos.map((todo) =>
                todo.id === id ? { ...todo, completed: !todo.completed } : todo
              ),
            }),
            false,
            'toggleTodo'
          ),
        
        setFilter: (filter) => set({ filter }, false, 'setFilter'),
        
        getFilteredTodos: () => {
          const { todos, filter } = get();
          switch (filter) {
            case 'active':
              return todos.filter((todo) => !todo.completed);
            case 'completed':
              return todos.filter((todo) => todo.completed);
            default:
              return todos;
          }
        },
        
        clearCompleted: () =>
          set(
            (state) => ({
              todos: state.todos.filter((todo) => !todo.completed),
            }),
            false,
            'clearCompleted'
          ),
      }),
      {
        name: 'todo-storage',
        partialize: (state) => ({ todos: state.todos }),
      }
    ),
    {
      name: 'todo-store',
    }
  )
);

// Usage in components
function TodoApp() {
  const {
    todos,
    filter,
    addTodo,
    toggleTodo,
    setFilter,
    getFilteredTodos,
    clearCompleted,
  } = useTodoStore();
  
  const filteredTodos = getFilteredTodos();
  
  return (
    <div>
      <TodoForm onAdd={addTodo} />
      <TodoFilter filter={filter} onFilterChange={setFilter} />
      <TodoList todos={filteredTodos} onToggle={toggleTodo} />
      <button onClick={clearCompleted}>Clear Completed</button>
    </div>
  );
}

Jotai - Atomic State Management

Jotai takes a bottom-up approach to React state management with an atomic model inspired by Recoil. It's perfect for fine-grained reactivity.

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Basic atoms
const countAtom = atom(0);
const nameAtom = atom('');

// Derived atoms
const doubledCountAtom = atom((get) => get(countAtom) * 2);
const greetingAtom = atom((get) => {
  const name = get(nameAtom);
  return name ? `Hello, ${name}!` : 'Hello, Anonymous!';
});

// Async atoms
const userAtom = atom(async () => {
  const response = await fetch('/api/user');
  return response.json();
});

// Storage atoms
const themeAtom = atomWithStorage('theme', 'light');
const settingsAtom = atomWithStorage('settings', {
  notifications: true,
  language: 'en',
});

// Component usage
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const doubledCount = useAtomValue(doubledCountAtom);
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubledCount}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </div>
  );
}

function Greeting() {
  const [name, setName] = useAtom(nameAtom);
  const greeting = useAtomValue(greetingAtom);
  
  return (
    <div>
      <input 
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Enter your name"
      />
      <p>{greeting}</p>
    </div>
  );
}

State Management Patterns

Lifting State Up

When multiple components need to share state, lift the state up to their common parent component.

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');
  
  const addTodo = (text) => {
    setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);
  };
  
  const toggleTodo = (id) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true;
  });
  
  return (
    <div>
      <TodoForm onAdd={addTodo} />
      <TodoFilter filter={filter} onFilterChange={setFilter} />
      <TodoList todos={filteredTodos} onToggle={toggleTodo} />
    </div>
  );
}

Custom Hooks for State Logic

Extract state logic into custom hooks to make it reusable across components.

// Custom hook for form state
function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  const handleChange = (name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    
    // Clear error when user starts typing
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  };
  
  const handleBlur = (name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
  };
  
  const validate = (validationRules) => {
    const newErrors = {};
    
    Object.keys(validationRules).forEach(field => {
      const rule = validationRules[field];
      const value = values[field];
      
      if (rule.required && !value) {
        newErrors[field] = `${field} is required`;
      } else if (rule.minLength && value.length < rule.minLength) {
        newErrors[field] = `${field} must be at least ${rule.minLength} characters`;
      } else if (rule.pattern && !rule.pattern.test(value)) {
        newErrors[field] = `${field} format is invalid`;
      }
    });
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const reset = () => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  };
  
  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validate,
    reset,
  };
}

// Usage in component
function ContactForm() {
  const {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validate,
    reset,
  } = useForm({
    name: '',
    email: '',
    message: ''
  });
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    const isValid = validate({
      name: { required: true, minLength: 2 },
      email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
      message: { required: true, minLength: 10 }
    });
    
    if (isValid) {
      // Submit form
      console.log('Form submitted:', values);
      reset();
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          name="name"
          value={values.name}
          onChange={(e) => handleChange('name', e.target.value)}
          onBlur={() => handleBlur('name')}
          placeholder="Your name"
        />
        {touched.name && errors.name && <span>{errors.name}</span>}
      </div>
      
      <div>
        <input
          name="email"
          type="email"
          value={values.email}
          onChange={(e) => handleChange('email', e.target.value)}
          onBlur={() => handleBlur('email')}
          placeholder="Your email"
        />
        {touched.email && errors.email && <span>{errors.email}</span>}
      </div>
      
      <div>
        <textarea
          name="message"
          value={values.message}
          onChange={(e) => handleChange('message', e.target.value)}
          onBlur={() => handleBlur('message')}
          placeholder="Your message"
        />
        {touched.message && errors.message && <span>{errors.message}</span>}
      </div>
      
      <button type="submit">Send Message</button>
    </form>
  );
}

MobX - Simple Reactive State Management

MobX is a battle-tested library that makes state management simple and scalable by transparently applying functional reactive programming. It uses observables to automatically track state dependencies.

import { makeObservable, observable, action, computed } from 'mobx';
import { observer } from 'mobx-react-lite';

// Store class
class TodoStore {
  todos = [];
  filter = 'all';
  
  constructor() {
    makeObservable(this, {
      todos: observable,
      filter: observable,
      addTodo: action,
      toggleTodo: action,
      setFilter: action,
      filteredTodos: computed,
      completedCount: computed,
    });
  }
  
  addTodo(text) {
    this.todos.push({
      id: Date.now(),
      text,
      completed: false,
    });
  }
  
  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
    }
  }
  
  setFilter(filter) {
    this.filter = filter;
  }
  
  get filteredTodos() {
    switch (this.filter) {
      case 'active':
        return this.todos.filter(t => !t.completed);
      case 'completed':
        return this.todos.filter(t => t.completed);
      default:
        return this.todos;
    }
  }
  
  get completedCount() {
    return this.todos.filter(t => t.completed).length;
  }
}

// Create store instance
const todoStore = new TodoStore();

// Observer component - automatically re-renders when observables change
const TodoApp = observer(() => {
  const [inputValue, setInputValue] = useState('');
  
  const handleAdd = () => {
    if (inputValue.trim()) {
      todoStore.addTodo(inputValue);
      setInputValue('');
    }
  };
  
  return (
    <div>
      <input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="Add todo"
      />
      <button onClick={handleAdd}>Add</button>
      
      <div>
        <button onClick={() => todoStore.setFilter('all')}>All</button>
        <button onClick={() => todoStore.setFilter('active')}>Active</button>
        <button onClick={() => todoStore.setFilter('completed')}>Completed</button>
      </div>
      
      <ul>
        {todoStore.filteredTodos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => todoStore.toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
          </li>
        ))}
      </ul>
      
      <p>Completed: {todoStore.completedCount} / {todoStore.todos.length}</p>
    </div>
  );
});

TanStack Query (React Query) - Server State Management

TanStack Query (formerly React Query) is a powerful library for fetching, caching, and updating server state in React applications. It eliminates boilerplate and provides automatic background refetching, caching, and synchronization.

Basic Usage

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

// Fetch data with useQuery
function UserProfile({ userId }) {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error('Failed to fetch user');
      return response.json();
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
  });
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      <button onClick={() => refetch()}>Refresh</button>
    </div>
  );
}

Mutations

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

function TodoForm() {
  const [text, setText] = useState('');
  const queryClient = useQueryClient();
  
  const addTodoMutation = useMutation({
    mutationFn: async (newTodo) => {
      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newTodo),
      });
      return response.json();
    },
    onSuccess: () => {
      // Invalidate and refetch todos
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      setText('');
    },
    onError: (error) => {
      alert(`Error: ${error.message}`);
    },
  });
  
  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      addTodoMutation.mutate({ text, completed: false });
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Add todo"
        disabled={addTodoMutation.isPending}
      />
      <button type="submit" disabled={addTodoMutation.isPending}>
        {addTodoMutation.isPending ? 'Adding...' : 'Add Todo'}
      </button>
    </form>
  );
}

Advanced Patterns

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

// Parallel queries
function Dashboard({ userId }) {
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });
  
  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPosts(userId),
  });
  
  const statsQuery = useQuery({
    queryKey: ['stats', userId],
    queryFn: () => fetchStats(userId),
  });
  
  if (userQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading) {
    return <div>Loading...</div>;
  }
  
  return (
    <div>
      <UserInfo user={userQuery.data} />
      <PostsList posts={postsQuery.data} />
      <StatsPanel stats={statsQuery.data} />
    </div>
  );
}

// Infinite scroll
function InfinitePosts() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam = 1 }) => {
      const response = await fetch(`/api/posts?page=${pageParam}`);
      return response.json();
    },
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
  });
  
  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}
      
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading more...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Choosing the Right State Management Solution

  • useState: Simple component state, form inputs, UI toggles

  • useReducer: Complex state logic, multiple related state values

  • Context API: Theme, authentication, user preferences

  • Redux: Large applications, complex state, time-travel debugging

  • Zustand: Simple global state, minimal boilerplate

  • Jotai: Fine-grained reactivity, atomic state updates

  • MobX: Reactive programming, automatic dependency tracking

  • TanStack Query: Server state management, caching, background sync

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free