React State Management - A Deep Dive

React State Management - A Deep Dive

React State Management: A Deep Dive

State management is one of the most critical aspects of building React applications. As applications grow in complexity, managing state effectively becomes increasingly important for maintaining code quality, performance, and developer experience. This guide explores various state management approaches in React, from built-in solutions to popular libraries, with practical examples and best practices.

Understanding State in React

Before diving into specific state management solutions, it's essential to understand what state is and why it matters in React applications.

What is State?

State represents the data that can change over time in your application. In React, state is used to:

  1. Store user interactions: Form inputs, toggles, selections
  2. Control UI elements: Show/hide components, change styles
  3. Cache data: Store API responses, computed values
  4. Track application status: Loading states, error conditions

Types of State

When building React applications, we typically deal with several types of state:

  1. UI State: Controls the visual aspects of the UI (modal open/closed, active tab)
  2. Form State: Manages form inputs, validation, and submission status
  3. Server State: Represents data fetched from APIs and its loading/error states
  4. URL State: Data stored in the URL (query parameters, route parameters)
  5. Global State: Application-wide data needed by many components

State Management Challenges

As applications grow, several challenges emerge with state management:

  1. Prop Drilling: Passing props through many component layers
  2. State Synchronization: Keeping related state in sync across components
  3. Performance: Preventing unnecessary re-renders
  4. Complexity: Managing increasingly complex state logic
  5. Persistence: Saving and restoring state across sessions

Built-in React State Management

React provides several built-in mechanisms for managing state, which are sufficient for many applications.

useState Hook

The useState hook is the simplest way to add state to functional components.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

useReducer Hook

For more complex state logic, the useReducer hook provides a Redux-like approach to state management.

import { useReducer } from 'react';

// Define the initial state
const initialState = { count: 0, lastAction: null };

// Define the reducer function
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1, lastAction: 'increment' };
    case 'decrement':
      return { count: state.count - 1, lastAction: 'decrement' };
    case 'reset':
      return { count: 0, lastAction: 'reset' };
    default:
      throw new Error('Unknown action type');
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  return (
    <div>
      <p>Count: {state.count}</p>
      <p>Last action: {state.lastAction || 'None'}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

Context API

React's Context API allows you to share state across components without prop drilling.

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

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

// Create a provider component
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };
  
  // The value that will be provided to consumers
  const value = {
    theme,
    toggleTheme
  };
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

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

// Usage in components
function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <button
      onClick={toggleTheme}
      style={{
        backgroundColor: theme === 'light' ? '#ffffff' : '#333333',
        color: theme === 'light' ? '#333333' : '#ffffff',
        padding: '10px 15px',
        border: '1px solid #ccc',
        borderRadius: '4px'
      }}
    >
      Toggle Theme
    </button>
  );
}

// App component with the provider
function App() {
  return (
    <ThemeProvider>
      <div style={{ padding: '20px' }}>
        <h1>Context API Example</h1>
        <ThemedButton />
      </div>
    </ThemeProvider>
  );
}

Combining Context and useReducer

For more complex state management needs, you can combine Context with useReducer to create a lightweight Redux-like solution.

import { createContext, useContext, useReducer } from 'react';

// Define the initial state
const initialState = {
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null
};

// Define the reducer
function authReducer(state, action) {
  switch (action.type) {
    case 'LOGIN_START':
      return { ...state, isLoading: true, error: null };
    case 'LOGIN_SUCCESS':
      return {
        ...state,
        user: action.payload,
        isAuthenticated: true,
        isLoading: false
      };
    case 'LOGIN_FAILURE':
      return {
        ...state,
        error: action.payload,
        isLoading: false
      };
    case 'LOGOUT':
      return {
        ...state,
        user: null,
        isAuthenticated: false
      };
    default:
      return state;
  }
}

// Create the context
const AuthContext = createContext();

// Create the provider
function AuthProvider({ children }) {
  const [state, dispatch] = useReducer(authReducer, initialState);
  
  // Define actions
  const login = async (credentials) => {
    try {
      dispatch({ type: 'LOGIN_START' });
      
      // Simulate API call
      const response = await new Promise((resolve) => {
        setTimeout(() => {
          if (credentials.username === 'admin' && credentials.password === 'password') {
            resolve({ id: 1, username: 'admin', name: 'Administrator' });
          } else {
            throw new Error('Invalid credentials');
          }
        }, 1000);
      });
      
      dispatch({ type: 'LOGIN_SUCCESS', payload: response });
    } catch (error) {
      dispatch({ type: 'LOGIN_FAILURE', payload: error.message });
    }
  };
  
  const logout = () => {
    dispatch({ type: 'LOGOUT' });
  };
  
  // Create the context value
  const value = {
    ...state,
    login,
    logout
  };
  
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook for using the auth context
function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

// Usage in components
function LoginForm() {
  const { login, isLoading, error } = useAuth();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    login({ username, password });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {error && <div style={{ color: 'red' }}>{error}</div>}
      <div>
        <label htmlFor="username">Username:</label>
        <input
          id="username"
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          required
        />
      </div>
      <div>
        <label htmlFor="password">Password:</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

function UserProfile() {
  const { user, isAuthenticated, logout } = useAuth();
  
  if (!isAuthenticated) {
    return <p>Please log in to view your profile.</p>;
  }
  
  return (
    <div>
      <h2>User Profile</h2>
      <p>Name: {user.name}</p>
      <p>Username: {user.username}</p>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

function App() {
  return (
    <AuthProvider>
      <div style={{ padding: '20px' }}>
        <h1>Auth Example</h1>
        <LoginForm />
        <UserProfile />
      </div>
    </AuthProvider>
  );
}

Redux: The Industry Standard

Redux has been the industry standard for state management in React applications for years. It provides a predictable state container with a unidirectional data flow.

Core Concepts

  1. Single Source of Truth: The entire application state is stored in a single store.
  2. State is Read-Only: The only way to change state is to dispatch an action.
  3. Changes are Made with Pure Functions: Reducers are pure functions that take the previous state and an action to return the new state.

Basic Redux Setup

import { createStore } from 'redux';
import { Provider, useSelector, useDispatch } from 'react-redux';

// Define action types
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
const RESET = 'RESET';

// Define action creators
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });
const reset = () => ({ type: RESET });

// Define the reducer
const initialState = { count: 0 };

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    case RESET:
      return { count: 0 };
    default:
      return state;
  }
}

// Create the store
const store = createStore(counterReducer);

// Counter component
function Counter() {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
      <button onClick={() => dispatch(reset())}>Reset</button>
    </div>
  );
}

// App component with Provider
function App() {
  return (
    <Provider store={store}>
      <div style={{ padding: '20px' }}>
        <h1>Redux Counter Example</h1>
        <Counter />
      </div>
    </Provider>
  );
}

Redux Toolkit

Redux Toolkit (RTK) is the official, opinionated, batteries-included toolset for efficient Redux development. It simplifies common Redux use cases, including store setup, creating reducers, and writing immutable update logic.

import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

// Create a slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: {
    increment: (state) => {
      // Redux Toolkit allows us to write "mutating" logic in reducers
      // It doesn't actually mutate the state because it uses Immer under the hood
      state.count += 1;
    },
    decrement: (state) => {
      state.count -= 1;
    },
    reset: (state) => {
      state.count = 0;
    },
    incrementByAmount: (state, action) => {
      state.count += action.payload;
    }
  }
});

// Extract the action creators and the reducer
const { actions, reducer } = counterSlice;
const { increment, decrement, reset, incrementByAmount } = actions;

// Create the store
const store = configureStore({
  reducer: {
    counter: reducer
  }
});

// Counter component
function Counter() {
  const count = useSelector(state => state.counter.count);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
      <button onClick={() => dispatch(reset())}>Reset</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>Add 5</button>
    </div>
  );
}

// App component with Provider
function App() {
  return (
    <Provider store={store}>
      <div style={{ padding: '20px' }}>
        <h1>Redux Toolkit Counter Example</h1>
        <Counter />
      </div>
    </Provider>
  );
}

Async Operations with Redux Toolkit

Redux Toolkit provides createAsyncThunk for handling asynchronous operations.

import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

// Create an async thunk for fetching todos
const fetchTodos = createAsyncThunk(
  'todos/fetchTodos',
  async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
    return response.json();
  }
);

// Create a slice for todos
const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  }
});

// Create the store
const store = configureStore({
  reducer: {
    todos: todosSlice.reducer
  }
});

// TodoList component
function TodoList() {
  const dispatch = useDispatch();
  const { items, status, error } = useSelector(state => state.todos);
  
  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchTodos());
    }
  }, [status, dispatch]);
  
  if (status === 'loading') {
    return <div>Loading...</div>;
  }
  
  if (status === 'failed') {
    return <div>Error: {error}</div>;
  }
  
  return (
    <div>
      <h2>Todos</h2>
      <ul>
        {items.map(todo => (
          <li key={todo.id}>
            {todo.title}
            {todo.completed ? ' ✓' : ''}
          </li>
        ))}
      </ul>
      <button 
        onClick={() => dispatch(fetchTodos())}
        disabled={status === 'loading'}
      >
        Refresh Todos
      </button>
    </div>
  );
}

// App component with Provider
function App() {
  return (
    <Provider store={store}>
      <div style={{ padding: '20px' }}>
        <h1>Redux Toolkit Async Example</h1>
        <TodoList />
      </div>
    </Provider>
  );
}

Modern State Management Libraries

While Redux remains popular, several newer libraries have emerged that offer simpler APIs and improved developer experience.

Zustand

Zustand is a small, fast, and scalable state management solution. It has a simple API that uses hooks and doesn't require providers.

import create from 'zustand';

// Create a store
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  incrementByAmount: (amount) => set((state) => ({ count: state.count + amount }))
}));

// Counter component
function Counter() {
  const { count, increment, decrement, reset, incrementByAmount } = useStore();
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
      <button onClick={() => incrementByAmount(5)}>Add 5</button>
    </div>
  );
}

// App component (no provider needed)
function App() {
  return (
    <div style={{ padding: '20px' }}>
      <h1>Zustand Counter Example</h1>
      <Counter />
    </div>
  );
}

Zustand with Async Actions

import create from 'zustand';

// Create a store with async actions
const useTodoStore = create((set) => ({
  todos: [],
  status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
  error: null,
  fetchTodos: async () => {
    try {
      set({ status: 'loading' });
      const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
      const todos = await response.json();
      set({ todos, status: 'succeeded' });
    } catch (error) {
      set({ error: error.message, status: 'failed' });
    }
  }
}));

// TodoList component
function TodoList() {
  const { todos, status, error, fetchTodos } = useTodoStore();
  
  useEffect(() => {
    if (status === 'idle') {
      fetchTodos();
    }
  }, [status, fetchTodos]);
  
  if (status === 'loading') {
    return <div>Loading...</div>;
  }
  
  if (status === 'failed') {
    return <div>Error: {error}</div>;
  }
  
  return (
    <div>
      <h2>Todos</h2>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            {todo.completed ? ' ✓' : ''}
          </li>
        ))}
      </ul>
      <button 
        onClick={fetchTodos}
        disabled={status === 'loading'}
      >
        Refresh Todos
      </button>
    </div>
  );
}

Jotai

Jotai is an atomic state management library for React that focuses on primitive atoms as the building blocks for state.

import { atom, useAtom } from 'jotai';

// Create atoms
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// Counter component
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubleCount] = useAtom(doubleCountAtom);
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Double Count: {doubleCount}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

// App component (no provider needed)
function App() {
  return (
    <div style={{ padding: '20px' }}>
      <h1>Jotai Counter Example</h1>
      <Counter />
    </div>
  );
}

Jotai with Async Atoms

import { atom, useAtom } from 'jotai';

// Create async atoms
const todosAtom = atom(async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
  return response.json();
});

const statusAtom = atom('idle');
const errorAtom = atom(null);

const refreshTodosAtom = atom(
  null, // read function not used
  async (get, set) => {
    try {
      set(statusAtom, 'loading');
      const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
      const todos = await response.json();
      set(todosAtom, todos);
      set(statusAtom, 'succeeded');
    } catch (error) {
      set(errorAtom, error.message);
      set(statusAtom, 'failed');
    }
  }
);

// TodoList component
function TodoList() {
  const [todos] = useAtom(todosAtom);
  const [status] = useAtom(statusAtom);
  const [error] = useAtom(errorAtom);
  const [, refreshTodos] = useAtom(refreshTodosAtom);
  
  if (status === 'loading') {
    return <div>Loading...</div>;
  }
  
  if (status === 'failed') {
    return <div>Error: {error}</div>;
  }
  
  return (
    <div>
      <h2>Todos</h2>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            {todo.completed ? ' ✓' : ''}
          </li>
        ))}
      </ul>
      <button 
        onClick={refreshTodos}
        disabled={status === 'loading'}
      >
        Refresh Todos
      </button>
    </div>
  );
}

Recoil

Recoil is a state management library developed by Facebook that provides several ways to manage and derive state.

import { RecoilRoot, atom, selector, useRecoilState, useRecoilValue } from 'recoil';

// Create atoms and selectors
const countState = atom({
  key: 'countState',
  default: 0
});

const doubleCountState = selector({
  key: 'doubleCountState',
  get: ({ get }) => {
    const count = get(countState);
    return count * 2;
  }
});

// Counter component
function Counter() {
  const [count, setCount] = useRecoilState(countState);
  const doubleCount = useRecoilValue(doubleCountState);
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>Double Count: {doubleCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

// App component with RecoilRoot
function App() {
  return (
    <RecoilRoot>
      <div style={{ padding: '20px' }}>
        <h1>Recoil Counter Example</h1>
        <Counter />
      </div>
    </RecoilRoot>
  );
}

State Management with React Query

React Query is a powerful library for managing server state, providing caching, background updates, and stale-while-revalidate functionality.

import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from 'react-query';

// Create a client
const queryClient = new QueryClient();

// API functions
const fetchTodos = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
};

const addTodo = async (newTodo) => {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
    method: 'POST',
    body: JSON.stringify(newTodo),
    headers: {
      'Content-type': 'application/json; charset=UTF-8',
    },
  });
  return response.json();
};

// TodoList component
function TodoList() {
  const queryClient = useQueryClient();
  const [newTodoTitle, setNewTodoTitle] = useState('');
  
  // Query for fetching todos
  const { data: todos, isLoading, isError, error, refetch } = useQuery(
    'todos',
    fetchTodos,
    {
      staleTime: 60000, // 1 minute
      cacheTime: 900000, // 15 minutes
    }
  );
  
  // Mutation for adding a todo
  const addTodoMutation = useMutation(addTodo, {
    onSuccess: (newTodo) => {
      // Optimistically update the cache
      queryClient.setQueryData('todos', (oldTodos) => [...oldTodos, newTodo]);
    },
  });
  
  const handleAddTodo = (e) => {
    e.preventDefault();
    if (!newTodoTitle.trim()) return;
    
    addTodoMutation.mutate({
      title: newTodoTitle,
      completed: false,
      userId: 1,
    });
    
    setNewTodoTitle('');
  };
  
  if (isLoading) {
    return <div>Loading...</div>;
  }
  
  if (isError) {
    return <div>Error: {error.message}</div>;
  }
  
  return (
    <div>
      <h2>Todos</h2>
      
      <form onSubmit={handleAddTodo}>
        <input
          type="text"
          value={newTodoTitle}
          onChange={(e) => setNewTodoTitle(e.target.value)}
          placeholder="Add a new todo"
        />
        <button 
          type="submit" 
          disabled={addTodoMutation.isLoading}
        >
          {addTodoMutation.isLoading ? 'Adding...' : 'Add Todo'}
        </button>
      </form>
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            {todo.completed ? ' ✓' : ''}
          </li>
        ))}
      </ul>
      
      <button onClick={() => refetch()}>Refresh Todos</button>
    </div>
  );
}

// App component with QueryClientProvider
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div style={{ padding: '20px' }}>
        <h1>React Query Example</h1>
        <TodoList />
      </div>
    </QueryClientProvider>
  );
}

State Management in Next.js Applications

Next.js applications have some unique considerations for state management due to server-side rendering (SSR) and static site generation (SSG).

Client-Side State Management

For client-side state in Next.js, you can use any of the libraries mentioned above. However, you need to ensure proper hydration by initializing your state management on the client side.

// pages/_app.js
import { useState, useEffect } from 'react';
import { Provider } from 'react-redux';
import { store } from '../store';

function MyApp({ Component, pageProps }) {
  // To avoid hydration mismatch, only render on the client
  const [mounted, setMounted] = useState(false);
  
  useEffect(() => {
    setMounted(true);
  }, []);
  
  if (!mounted) {
    return null;
  }
  
  return (
    <Provider store={store}>
      <Component {...pageProps} />
    </Provider>
  );
}

export default MyApp;

Server-Side State Initialization

For data that needs to be available during server-side rendering, you can use Next.js data fetching methods to initialize your state.

// pages/index.js
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { initializeStore, setTodos } from '../store/todosSlice';

export async function getServerSideProps() {
  // Fetch data on the server
  const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
  const todos = await response.json();
  
  // Initialize the store with the fetched data
  const store = initializeStore();
  store.dispatch(setTodos(todos));
  
  return {
    props: {
      initialState: store.getState(),
    },
  };
}

function HomePage({ initialState }) {
  const dispatch = useDispatch();
  const { todos } = useSelector(state => state.todos);
  
  // Initialize the client-side store with the server-side state
  useEffect(() => {
    if (initialState) {
      dispatch(setTodos(initialState.todos.items));
    }
  }, [dispatch, initialState]);
  
  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default HomePage;

Using React Query with Next.js

React Query works particularly well with Next.js, as it provides utilities for prefetching and hydrating the query cache.

// pages/_app.js
import { useState } from 'react';
import { Hydrate, QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';

function MyApp({ Component, pageProps }) {
  // Create a new QueryClient instance for each request
  const [queryClient] = useState(() => new QueryClient());
  
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
        <ReactQueryDevtools initialIsOpen={false} />
      </Hydrate>
    </QueryClientProvider>
  );
}

export default MyApp;
// pages/todos.js
import { dehydrate, QueryClient, useQuery } from 'react-query';

// API function
const fetchTodos = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
  return response.json();
};

export async function getStaticProps() {
  const queryClient = new QueryClient();
  
  // Prefetch the todos query
  await queryClient.prefetchQuery('todos', fetchTodos);
  
  return {
    props: {
      // Dehydrate the query cache
      dehydratedState: dehydrate(queryClient),
    },
    revalidate: 60, // Revalidate every 60 seconds
  };
}

function TodosPage() {
  // The query is already in the cache from SSR/SSG
  const { data, isLoading, isError } = useQuery('todos', fetchTodos);
  
  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error loading todos</div>;
  
  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {data.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

export default TodosPage;

Best Practices for State Management

1. Choose the Right Tool for the Job

Different state management solutions are suited for different use cases:

  • Local Component State: Use useState for simple, component-specific state
  • Shared State: Use Context API for state shared between a few components
  • Complex Application State: Use Redux, Zustand, or Jotai for complex global state
  • Server State: Use React Query or SWR for data fetching and caching

2. Separate UI State from Server State

UI state and server state have different characteristics and should be managed differently:

  • UI State: Typically short-lived, synchronous, and client-specific
  • Server State: Represents remote data, requires caching, synchronization, and error handling

3. Normalize Complex State

For complex data structures, normalize your state to avoid duplication and make updates easier:

// Instead of this
const state = {
  posts: [
    {
      id: 1,
      title: 'Post 1',
      author: { id: 1, name: 'John' },
      comments: [
        { id: 1, text: 'Comment 1', author: { id: 2, name: 'Jane' } }
      ]
    }
  ]
};

// Do this
const state = {
  posts: {
    byId: {
      1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1] }
    },
    allIds: [1]
  },
  users: {
    byId: {
      1: { id: 1, name: 'John' },
      2: { id: 2, name: 'Jane' }
    },
    allIds: [1, 2]
  },
  comments: {
    byId: {
      1: { id: 1, text: 'Comment 1', authorId: 2 }
    },
    allIds: [1]
  }
};

4. Use Selectors for Derived State

Compute derived state using selectors rather than storing it directly:

// Redux Toolkit example with createSelector
import { createSelector } from '@reduxjs/toolkit';

// Base selectors
const selectTodos = state => state.todos.items;
const selectFilter = state => state.todos.filter;

// Derived selectors
const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'completed':
        return todos.filter(todo => todo.completed);
      case 'active':
        return todos.filter(todo => !todo.completed);
      default:
        return todos;
    }
  }
);

const selectTodoStats = createSelector(
  [selectTodos],
  (todos) => {
    const total = todos.length;
    const completed = todos.filter(todo => todo.completed).length;
    const active = total - completed;
    const percentComplete = total === 0 ? 0 : Math.round((completed / total) * 100);
    
    return { total, completed, active, percentComplete };
  }
);

5. Optimize Renders

Prevent unnecessary re-renders by optimizing your state access:

// Bad: Component re-renders when any part of state changes
function UserProfile() {
  const state = useSelector(state => state);
  return <div>{state.user.name}</div>;
}

// Good: Component only re-renders when user.name changes
function UserProfile() {
  const userName = useSelector(state => state.user.name);
  return <div>{userName}</div>;
}

6. Use Middleware for Side Effects

Handle side effects like API calls using middleware:

// Redux Thunk example
const fetchUserThunk = (userId) => async (dispatch) => {
  dispatch({ type: 'user/fetchStart' });
  
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    const user = await response.json();
    dispatch({ type: 'user/fetchSuccess', payload: user });
  } catch (error) {
    dispatch({ type: 'user/fetchError', payload: error.message });
  }
};

// Redux Saga example
function* fetchUserSaga(action) {
  try {
    yield put({ type: 'user/fetchStart' });
    const user = yield call(fetchUser, action.payload);
    yield put({ type: 'user/fetchSuccess', payload: user });
  } catch (error) {
    yield put({ type: 'user/fetchError', payload: error.message });
  }
}

7. Persist State When Needed

Persist state to localStorage or other storage mechanisms for a better user experience:

// Redux with redux-persist
import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import rootReducer from './reducers';

const persistConfig = {
  key: 'root',
  storage,
  whitelist: ['user', 'preferences'], // Only persist these reducers
};

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['persist/PERSIST'],
      },
    }),
});

export const persistor = persistStore(store);
// In your app
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import { store, persistor } from './store';

function App() {
  return (
    <Provider store={store}>
      <PersistGate loading={<div>Loading...</div>} persistor={persistor}>
        <YourApp />
      </PersistGate>
    </Provider>
  );
}

Conclusion

State management is a critical aspect of React application development. The right approach depends on your application's size, complexity, and specific requirements. For smaller applications, React's built-in state management with useState, useReducer, and Context API may be sufficient. For larger, more complex applications, libraries like Redux, Zustand, Jotai, or Recoil provide more powerful tools for managing global state.

Remember that you don't have to choose a single solution for your entire application. It's common to use different approaches for different types of state:

  • Local UI state with useState
  • Form state with a form library like Formik or React Hook Form
  • Server state with React Query or SWR
  • Global UI state with Context, Redux, or another state management library

By understanding the strengths and weaknesses of each approach, you can make informed decisions about state management in your React applications, leading to more maintainable, performant, and developer-friendly code.