State Management in Modern React Applications
Featured
11/28/2023
14 min read
Tridip Dutta
Web Development

State Management in Modern React Applications

Comprehensive guide to state management patterns in React, comparing Redux, Zustand, Jotai, and React's built-in state solutions.

React
State Management
Redux
Frontend

State Management in Modern React Applications

State management is the backbone of any React application, determining how data flows through your components and how your application responds to user interactions. As React applications grow in complexity, choosing the right state management approach becomes crucial for maintainability, performance, and developer experience. This comprehensive guide explores every aspect of modern state management, from React's built-in capabilities to sophisticated third-party solutions.

Understanding State in React Applications

State represents the data that drives your UI at any given moment. It's the single source of truth that determines what users see and how they can interact with your application. Effective state management ensures:

  • Predictable UI behavior across all components
  • Optimal performance through efficient re-renders
  • Maintainable codebase that scales with your team
  • Debugging capabilities for complex data flows
  • Type safety in TypeScript applications

Types of State in React Applications

Understanding different types of state helps you choose the right management approach:

// 1. Local Component State
function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  )
}

// 2. Shared State (between components)
function App() {
  const [user, setUser] = useState(null)
  return (
    <div>
      <Header user={user} />
      <Profile user={user} onUpdate={setUser} />
    </div>
  )
}

// 3. Global Application State
const globalState = {
  user: { id: 1, name: 'John' },
  theme: 'dark',
  notifications: [],
  cart: { items: [], total: 0 }
}

// 4. Server State (cached from API)
const serverState = {
  posts: { data: [...], loading: false, error: null },
  users: { data: [...], loading: true, error: null }
}

// 5. URL State (from routing)
const urlState = {
  pathname: '/dashboard',
  searchParams: { tab: 'analytics', filter: 'recent' }
}

React's Built-in State Management

useState: The Foundation

useState is perfect for local component state and simple shared state scenarios:

// Basic usage
function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all')

  const addTodo = (text: string) => {
    const newTodo = {
      id: Date.now(),
      text,
      completed: false,
      createdAt: new Date()
    }
    setTodos(prev => [...prev, newTodo])
  }

  const toggleTodo = (id: number) => {
    setTodos(prev => 
      prev.map(todo => 
        todo.id === id 
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    )
  }

  const filteredTodos = useMemo(() => {
    switch (filter) {
      case 'active':
        return todos.filter(todo => !todo.completed)
      case 'completed':
        return todos.filter(todo => todo.completed)
      default:
        return todos
    }
  }, [todos, filter])

  return (
    <div>
      <TodoInput onAdd={addTodo} />
      <FilterButtons filter={filter} onFilterChange={setFilter} />
      <TodoItems todos={filteredTodos} onToggle={toggleTodo} />
    </div>
  )
}

useReducer: Complex State Logic

When state logic becomes complex, useReducer provides better organization:

// Define state and actions
interface TodoState {
  todos: Todo[]
  filter: 'all' | 'active' | 'completed'
  loading: boolean
  error: string | null
}

type TodoAction =
  | { type: 'ADD_TODO'; payload: { text: string } }
  | { type: 'TOGGLE_TODO'; payload: { id: number } }
  | { type: 'DELETE_TODO'; payload: { id: number } }
  | { type: 'SET_FILTER'; payload: { filter: TodoState['filter'] } }
  | { type: 'SET_LOADING'; payload: { loading: boolean } }
  | { type: 'SET_ERROR'; payload: { error: string | null } }
  | { type: 'LOAD_TODOS_SUCCESS'; payload: { todos: Todo[] } }

// Reducer function
function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: Date.now(),
            text: action.payload.text,
            completed: false,
            createdAt: new Date()
          }
        ]
      }
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      }
    
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload.id)
      }
    
    case 'SET_FILTER':
      return {
        ...state,
        filter: action.payload.filter
      }
    
    case 'SET_LOADING':
      return {
        ...state,
        loading: action.payload.loading
      }
    
    case 'SET_ERROR':
      return {
        ...state,
        error: action.payload.error,
        loading: false
      }
    
    case 'LOAD_TODOS_SUCCESS':
      return {
        ...state,
        todos: action.payload.todos,
        loading: false,
        error: null
      }
    
    default:
      return state
  }
}

// Component using useReducer
function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: [],
    filter: 'all',
    loading: false,
    error: null
  })

  const addTodo = (text: string) => {
    dispatch({ type: 'ADD_TODO', payload: { text } })
  }

  const toggleTodo = (id: number) => {
    dispatch({ type: 'TOGGLE_TODO', payload: { id } })
  }

  const loadTodos = async () => {
    dispatch({ type: 'SET_LOADING', payload: { loading: true } })
    try {
      const todos = await fetchTodos()
      dispatch({ type: 'LOAD_TODOS_SUCCESS', payload: { todos } })
    } catch (error) {
      dispatch({ type: 'SET_ERROR', payload: { error: error.message } })
    }
  }

  useEffect(() => {
    loadTodos()
  }, [])

  return (
    <div>
      {state.loading && <LoadingSpinner />}
      {state.error && <ErrorMessage error={state.error} />}
      <TodoList 
        todos={state.todos}
        filter={state.filter}
        onAddTodo={addTodo}
        onToggleTodo={toggleTodo}
        onFilterChange={(filter) => 
          dispatch({ type: 'SET_FILTER', payload: { filter } })
        }
      />
    </div>
  )
}

Context API: Sharing State Across Components

React Context provides a way to share state without prop drilling:

// Create typed context
interface AppContextType {
  user: User | null
  theme: 'light' | 'dark'
  notifications: Notification[]
  login: (credentials: LoginCredentials) => Promise<void>
  logout: () => void
  toggleTheme: () => void
  addNotification: (notification: Omit<Notification, 'id'>) => void
  removeNotification: (id: string) => void
}

const AppContext = createContext<AppContextType | undefined>(undefined)

// Custom hook for using context
export function useAppContext() {
  const context = useContext(AppContext)
  if (context === undefined) {
    throw new Error('useAppContext must be used within an AppProvider')
  }
  return context
}

// Provider component
export function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  const [notifications, setNotifications] = useState<Notification[]>([])

  const login = async (credentials: LoginCredentials) => {
    try {
      const user = await authService.login(credentials)
      setUser(user)
      addNotification({
        type: 'success',
        message: 'Successfully logged in',
        duration: 3000
      })
    } catch (error) {
      addNotification({
        type: 'error',
        message: 'Login failed',
        duration: 5000
      })
      throw error
    }
  }

  const logout = () => {
    setUser(null)
    authService.logout()
    addNotification({
      type: 'info',
      message: 'You have been logged out',
      duration: 3000
    })
  }

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }

  const addNotification = (notification: Omit<Notification, 'id'>) => {
    const id = Math.random().toString(36).substr(2, 9)
    const newNotification = { ...notification, id }
    
    setNotifications(prev => [...prev, newNotification])
    
    if (notification.duration) {
      setTimeout(() => {
        removeNotification(id)
      }, notification.duration)
    }
  }

  const removeNotification = (id: string) => {
    setNotifications(prev => prev.filter(n => n.id !== id))
  }

  const value: AppContextType = {
    user,
    theme,
    notifications,
    login,
    logout,
    toggleTheme,
    addNotification,
    removeNotification
  }

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

// Usage in components
function Header() {
  const { user, theme, toggleTheme, logout } = useAppContext()

  return (
    <header className={`header ${theme}`}>
      <div className="header-content">
        <Logo />
        <nav>
          <ThemeToggle theme={theme} onToggle={toggleTheme} />
          {user ? (
            <UserMenu user={user} onLogout={logout} />
          ) : (
            <LoginButton />
          )}
        </nav>
      </div>
    </header>
  )
}

Redux Toolkit: The Modern Redux Experience

Redux Toolkit (RTK) has revolutionized Redux development by reducing boilerplate and promoting best practices:

Setting Up Redux Toolkit

// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { authSlice } from './slices/authSlice'
import { todosSlice } from './slices/todosSlice'
import { uiSlice } from './slices/uiSlice'
import { apiSlice } from './slices/apiSlice'

export const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
    todos: todosSlice.reducer,
    ui: uiSlice.reducer,
    api: apiSlice.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
      },
    }).concat(apiSlice.middleware),
  devTools: process.env.NODE_ENV !== 'production',
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

Creating Feature Slices

// store/slices/todosSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'

interface TodosState {
  items: Todo[]
  filter: 'all' | 'active' | 'completed'
  loading: boolean
  error: string | null
  lastUpdated: number | null
}

const initialState: TodosState = {
  items: [],
  filter: 'all',
  loading: false,
  error: null,
  lastUpdated: null,
}

// Async thunks for API calls
export const fetchTodos = createAsyncThunk(
  'todos/fetchTodos',
  async (_, { rejectWithValue }) => {
    try {
      const response = await todosApi.getAll()
      return response.data
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

export const createTodo = createAsyncThunk(
  'todos/createTodo',
  async (todoData: CreateTodoData, { rejectWithValue }) => {
    try {
      const response = await todosApi.create(todoData)
      return response.data
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

export const updateTodo = createAsyncThunk(
  'todos/updateTodo',
  async ({ id, updates }: { id: number; updates: Partial<Todo> }, { rejectWithValue }) => {
    try {
      const response = await todosApi.update(id, updates)
      return response.data
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

export const deleteTodo = createAsyncThunk(
  'todos/deleteTodo',
  async (id: number, { rejectWithValue }) => {
    try {
      await todosApi.delete(id)
      return id
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

// Slice definition
export const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
      state.filter = action.payload
    },
    clearError: (state) => {
      state.error = null
    },
    toggleTodoLocal: (state, action: PayloadAction<number>) => {
      const todo = state.items.find(item => item.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
  },
  extraReducers: (builder) => {
    builder
      // Fetch todos
      .addCase(fetchTodos.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.loading = false
        state.items = action.payload
        state.lastUpdated = Date.now()
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.loading = false
        state.error = action.payload as string
      })
      
      // Create todo
      .addCase(createTodo.fulfilled, (state, action) => {
        state.items.push(action.payload)
        state.lastUpdated = Date.now()
      })
      .addCase(createTodo.rejected, (state, action) => {
        state.error = action.payload as string
      })
      
      // Update todo
      .addCase(updateTodo.fulfilled, (state, action) => {
        const index = state.items.findIndex(item => item.id === action.payload.id)
        if (index !== -1) {
          state.items[index] = action.payload
        }
        state.lastUpdated = Date.now()
      })
      .addCase(updateTodo.rejected, (state, action) => {
        state.error = action.payload as string
      })
      
      // Delete todo
      .addCase(deleteTodo.fulfilled, (state, action) => {
        state.items = state.items.filter(item => item.id !== action.payload)
        state.lastUpdated = Date.now()
      })
      .addCase(deleteTodo.rejected, (state, action) => {
        state.error = action.payload as string
      })
  },
})

export const { setFilter, clearError, toggleTodoLocal } = todosSlice.actions

// Selectors
export const selectTodos = (state: RootState) => state.todos.items
export const selectTodosFilter = (state: RootState) => state.todos.filter
export const selectTodosLoading = (state: RootState) => state.todos.loading
export const selectTodosError = (state: RootState) => state.todos.error

export const selectFilteredTodos = (state: RootState) => {
  const todos = selectTodos(state)
  const filter = selectTodosFilter(state)
  
  switch (filter) {
    case 'active':
      return todos.filter(todo => !todo.completed)
    case 'completed':
      return todos.filter(todo => todo.completed)
    default:
      return todos
  }
}

export const selectTodosStats = (state: RootState) => {
  const todos = selectTodos(state)
  return {
    total: todos.length,
    completed: todos.filter(todo => todo.completed).length,
    active: todos.filter(todo => !todo.completed).length,
  }
}

RTK Query for Server State

// store/slices/apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token
      if (token) {
        headers.set('authorization', `Bearer ${token}`)
      }
      return headers
    },
  }),
  tagTypes: ['Todo', 'User', 'Project'],
  endpoints: (builder) => ({
    // Todos endpoints
    getTodos: builder.query<Todo[], void>({
      query: () => '/todos',
      providesTags: ['Todo'],
    }),
    
    getTodo: builder.query<Todo, number>({
      query: (id) => `/todos/${id}`,
      providesTags: (result, error, id) => [{ type: 'Todo', id }],
    }),
    
    createTodo: builder.mutation<Todo, CreateTodoData>({
      query: (newTodo) => ({
        url: '/todos',
        method: 'POST',
        body: newTodo,
      }),
      invalidatesTags: ['Todo'],
    }),
    
    updateTodo: builder.mutation<Todo, { id: number; updates: Partial<Todo> }>({
      query: ({ id, updates }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: updates,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Todo', id }],
    }),
    
    deleteTodo: builder.mutation<void, number>({
      query: (id) => ({
        url: `/todos/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Todo', id }],
    }),
    
    // Users endpoints
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    
    getUser: builder.query<User, string>({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
  }),
})

export const {
  useGetTodosQuery,
  useGetTodoQuery,
  useCreateTodoMutation,
  useUpdateTodoMutation,
  useDeleteTodoMutation,
  useGetUsersQuery,
  useGetUserQuery,
} = apiSlice

Using Redux in Components

// hooks/redux.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from '../store'

export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

// components/TodoList.tsx
import { useAppSelector, useAppDispatch } from '../hooks/redux'
import { 
  fetchTodos, 
  createTodo, 
  updateTodo, 
  deleteTodo,
  setFilter,
  selectFilteredTodos,
  selectTodosLoading,
  selectTodosError,
  selectTodosStats
} from '../store/slices/todosSlice'

function TodoList() {
  const dispatch = useAppDispatch()
  const todos = useAppSelector(selectFilteredTodos)
  const loading = useAppSelector(selectTodosLoading)
  const error = useAppSelector(selectTodosError)
  const stats = useAppSelector(selectTodosStats)
  const filter = useAppSelector(state => state.todos.filter)

  useEffect(() => {
    dispatch(fetchTodos())
  }, [dispatch])

  const handleCreateTodo = async (text: string) => {
    try {
      await dispatch(createTodo({ text })).unwrap()
    } catch (error) {
      console.error('Failed to create todo:', error)
    }
  }

  const handleToggleTodo = async (id: number) => {
    const todo = todos.find(t => t.id === id)
    if (todo) {
      try {
        await dispatch(updateTodo({ 
          id, 
          updates: { completed: !todo.completed } 
        })).unwrap()
      } catch (error) {
        console.error('Failed to update todo:', error)
      }
    }
  }

  const handleDeleteTodo = async (id: number) => {
    try {
      await dispatch(deleteTodo(id)).unwrap()
    } catch (error) {
      console.error('Failed to delete todo:', error)
    }
  }

  if (loading) return <LoadingSpinner />
  if (error) return <ErrorMessage error={error} />

  return (
    <div className="todo-list">
      <TodoStats stats={stats} />
      <TodoInput onSubmit={handleCreateTodo} />
      <TodoFilters 
        currentFilter={filter} 
        onFilterChange={(filter) => dispatch(setFilter(filter))} 
      />
      <div className="todos">
        {todos.map(todo => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={() => handleToggleTodo(todo.id)}
            onDelete={() => handleDeleteTodo(todo.id)}
          />
        ))}
      </div>
    </div>
  )
}

Zustand: Lightweight State Management

Zustand offers a minimalist approach to state management with excellent TypeScript support:

// store/useStore.ts
import { create } from 'zustand'
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface Todo {
  id: number
  text: string
  completed: boolean
  createdAt: Date
}

interface TodoStore {
  // State
  todos: Todo[]
  filter: 'all' | 'active' | 'completed'
  loading: boolean
  error: string | null
  
  // Actions
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  deleteTodo: (id: number) => void
  setFilter: (filter: 'all' | 'active' | 'completed') => void
  clearCompleted: () => void
  
  // Async actions
  fetchTodos: () => Promise<void>
  saveTodo: (todo: Omit<Todo, 'id' | 'createdAt'>) => Promise<void>
  
  // Computed values
  filteredTodos: () => Todo[]
  stats: () => { total: number; completed: number; active: number }
}

export const useTodoStore = create<TodoStore>()(
  devtools(
    persist(
      subscribeWithSelector(
        immer((set, get) => ({
          // Initial state
          todos: [],
          filter: 'all',
          loading: false,
          error: null,
          
          // Actions
          addTodo: (text) => {
            set((state) => {
              state.todos.push({
                id: Date.now(),
                text,
                completed: false,
                createdAt: new Date(),
              })
            })
          },
          
          toggleTodo: (id) => {
            set((state) => {
              const todo = state.todos.find(t => t.id === id)
              if (todo) {
                todo.completed = !todo.completed
              }
            })
          },
          
          deleteTodo: (id) => {
            set((state) => {
              state.todos = state.todos.filter(t => t.id !== id)
            })
          },
          
          setFilter: (filter) => {
            set({ filter })
          },
          
          clearCompleted: () => {
            set((state) => {
              state.todos = state.todos.filter(t => !t.completed)
            })
          },
          
          // Async actions
          fetchTodos: async () => {
            set({ loading: true, error: null })
            try {
              const todos = await todosApi.getAll()
              set({ todos, loading: false })
            } catch (error) {
              set({ error: error.message, loading: false })
            }
          },
          
          saveTodo: async (todoData) => {
            set({ loading: true })
            try {
              const todo = await todosApi.create(todoData)
              set((state) => {
                state.todos.push(todo)
                state.loading = false
              })
            } catch (error) {
              set({ error: error.message, loading: false })
            }
          },
          
          // Computed values
          filteredTodos: () => {
            const { todos, filter } = get()
            switch (filter) {
              case 'active':
                return todos.filter(t => !t.completed)
              case 'completed':
                return todos.filter(t => t.completed)
              default:
                return todos
            }
          },
          
          stats: () => {
            const todos = get().todos
            return {
              total: todos.length,
              completed: todos.filter(t => t.completed).length,
              active: todos.filter(t => !t.completed).length,
            }
          },
        }))
      ),
      {
        name: 'todo-storage',
        partialize: (state) => ({ 
          todos: state.todos, 
          filter: state.filter 
        }),
      }
    ),
    { name: 'todo-store' }
  )
)

// Selectors for optimized re-renders
export const useTodos = () => useTodoStore(state => state.filteredTodos())
export const useTodoStats = () => useTodoStore(state => state.stats())
export const useTodoActions = () => useTodoStore(state => ({
  addTodo: state.addTodo,
  toggleTodo: state.toggleTodo,
  deleteTodo: state.deleteTodo,
  setFilter: state.setFilter,
  clearCompleted: state.clearCompleted,
  fetchTodos: state.fetchTodos,
}))

Using Zustand in Components

// components/TodoApp.tsx
function TodoApp() {
  const todos = useTodos()
  const stats = useTodoStats()
  const { addTodo, toggleTodo, deleteTodo, setFilter, fetchTodos } = useTodoActions()
  const loading = useTodoStore(state => state.loading)
  const error = useTodoStore(state => state.error)
  const filter = useTodoStore(state => state.filter)

  useEffect(() => {
    fetchTodos()
  }, [fetchTodos])

  if (loading) return <LoadingSpinner />
  if (error) return <ErrorMessage error={error} />

  return (
    <div className="todo-app">
      <TodoHeader stats={stats} />
      <TodoInput onSubmit={addTodo} />
      <TodoFilters currentFilter={filter} onFilterChange={setFilter} />
      <TodoList 
        todos={todos} 
        onToggle={toggleTodo} 
        onDelete={deleteTodo} 
      />
    </div>
  )
}

// Subscription to state changes
function TodoNotifications() {
  useEffect(() => {
    const unsubscribe = useTodoStore.subscribe(
      (state) => state.todos,
      (todos, prevTodos) => {
        if (todos.length > prevTodos.length) {
          toast.success('Todo added successfully!')
        }
      }
    )
    
    return unsubscribe
  }, [])

  return null
}

Jotai: Atomic State Management

Jotai provides a bottom-up approach to state management using atomic values:

// atoms/todoAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage, atomWithReset } from 'jotai/utils'

// Base atoms
export const todosAtom = atomWithStorage<Todo[]>('todos', [])
export const filterAtom = atom<'all' | 'active' | 'completed'>('all')
export const loadingAtom = atom(false)
export const errorAtom = atomWithReset<string | null>(null)

// Derived atoms
export const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom)
  const filter = get(filterAtom)
  
  switch (filter) {
    case 'active':
      return todos.filter(todo => !todo.completed)
    case 'completed':
      return todos.filter(todo => todo.completed)
    default:
      return todos
  }
})

export const todoStatsAtom = atom((get) => {
  const todos = get(todosAtom)
  return {
    total: todos.length,
    completed: todos.filter(todo => todo.completed).length,
    active: todos.filter(todo => !todo.completed).length,
  }
})

// Write-only atoms for actions
export const addTodoAtom = atom(
  null,
  (get, set, text: string) => {
    const todos = get(todosAtom)
    const newTodo: Todo = {
      id: Date.now(),
      text,
      completed: false,
      createdAt: new Date(),
    }
    set(todosAtom, [...todos, newTodo])
  }
)

export const toggleTodoAtom = atom(
  null,
  (get, set, id: number) => {
    const todos = get(todosAtom)
    set(
      todosAtom,
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    )
  }
)

export const deleteTodoAtom = atom(
  null,
  (get, set, id: number) => {
    const todos = get(todosAtom)
    set(todosAtom, todos.filter(todo => todo.id !== id))
  }
)

// Async atoms
export const fetchTodosAtom = atom(
  null,
  async (get, set) => {
    set(loadingAtom, true)
    set(errorAtom, null)
    
    try {
      const todos = await todosApi.getAll()
      set(todosAtom, todos)
    } catch (error) {
      set(errorAtom, error.message)
    } finally {
      set(loadingAtom, false)
    }
  }
)

export const saveTodoAtom = atom(
  null,
  async (get, set, todoData: Omit<Todo, 'id' | 'createdAt'>) => {
    set(loadingAtom, true)
    
    try {
      const todo = await todosApi.create(todoData)
      const todos = get(todosAtom)
      set(todosAtom, [...todos, todo])
    } catch (error) {
      set(errorAtom, error.message)
    } finally {
      set(loadingAtom, false)
    }
  }
)

Using Jotai in Components

// components/TodoApp.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import {
  filteredTodosAtom,
  todoStatsAtom,
  filterAtom,
  loadingAtom,
  errorAtom,
  addTodoAtom,
  toggleTodoAtom,
  deleteTodoAtom,
  fetchTodosAtom,
} from '../atoms/todoAtoms'

function TodoApp() {
  const todos = useAtomValue(filteredTodosAtom)
  const stats = useAtomValue(todoStatsAtom)
  const [filter, setFilter] = useAtom(filterAtom)
  const loading = useAtomValue(loadingAtom)
  const error = useAtomValue(errorAtom)
  
  const addTodo = useSetAtom(addTodoAtom)
  const toggleTodo = useSetAtom(toggleTodoAtom)
  const deleteTodo = useSetAtom(deleteTodoAtom)
  const fetchTodos = useSetAtom(fetchTodosAtom)

  useEffect(() => {
    fetchTodos()
  }, [fetchTodos])

  if (loading) return <LoadingSpinner />
  if (error) return <ErrorMessage error={error} />

  return (
    <div className="todo-app">
      <TodoHeader stats={stats} />
      <TodoInput onSubmit={addTodo} />
      <TodoFilters currentFilter={filter} onFilterChange={setFilter} />
      <TodoList 
        todos={todos} 
        onToggle={toggleTodo} 
        onDelete={deleteTodo} 
      />
    </div>
  )
}

// Optimized component that only re-renders when specific atoms change
function TodoStats() {
  const stats = useAtomValue(todoStatsAtom)
  
  return (
    <div className="todo-stats">
      <span>Total: {stats.total}</span>
      <span>Active: {stats.active}</span>
      <span>Completed: {stats.completed}</span>
    </div>
  )
}

Server State Management

TanStack Query (React Query)

For server state management, TanStack Query is the gold standard:

// hooks/useTodos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { todosApi } from '../api/todos'

export function useTodos() {
  return useQuery({
    queryKey: ['todos'],
    queryFn: todosApi.getAll,
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
  })
}

export function useTodo(id: number) {
  return useQuery({
    queryKey: ['todos', id],
    queryFn: () => todosApi.getById(id),
    enabled: !!id,
  })
}

export function useCreateTodo() {
  const queryClient = useQueryClient()
  
  return useMutation({
    mutationFn: todosApi.create,
    onSuccess: (newTodo) => {
      // Update the todos list
      queryClient.setQueryData(['todos'], (old: Todo[] = []) => [
        ...old,
        newTodo,
      ])
      
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
    onError: (error) => {
      console.error('Failed to create todo:', error)
    },
  })
}

export function useUpdateTodo() {
  const queryClient = useQueryClient()
  
  return useMutation({
    mutationFn: ({ id, updates }: { id: number; updates: Partial<Todo> }) =>
      todosApi.update(id, updates),
    onMutate: async ({ id, updates }) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['todos'] })
      await queryClient.cancelQueries({ queryKey: ['todos', id] })
      
      // Snapshot previous values
      const previousTodos = queryClient.getQueryData(['todos'])
      const previousTodo = queryClient.getQueryData(['todos', id])
      
      // Optimistically update
      queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
        old.map(todo => todo.id === id ? { ...todo, ...updates } : todo)
      )
      
      queryClient.setQueryData(['todos', id], (old: Todo) => 
        old ? { ...old, ...updates } : old
      )
      
      return { previousTodos, previousTodo }
    },
    onError: (error, variables, context) => {
      // Rollback on error
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos)
      }
      if (context?.previousTodo) {
        queryClient.setQueryData(['todos', variables.id], context.previousTodo)
      }
    },
    onSettled: () => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
}

export function useDeleteTodo() {
  const queryClient = useQueryClient()
  
  return useMutation({
    mutationFn: todosApi.delete,
    onSuccess: (_, deletedId) => {
      queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
        old.filter(todo => todo.id !== deletedId)
      )
      queryClient.removeQueries({ queryKey: ['todos', deletedId] })
    },
  })
}

// Infinite query for pagination
export function useInfiniteTodos() {
  return useInfiniteQuery({
    queryKey: ['todos', 'infinite'],
    queryFn: ({ pageParam = 1 }) => todosApi.getPaginated(pageParam),
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined
    },
    staleTime: 5 * 60 * 1000,
  })
}

SWR Alternative

// hooks/useTodosSWR.ts
import useSWR, { mutate } from 'swr'
import useSWRMutation from 'swr/mutation'

const fetcher = (url: string) => fetch(url).then(res => res.json())

export function useTodos() {
  const { data, error, isLoading } = useSWR('/api/todos', fetcher, {
    revalidateOnFocus: false,
    revalidateOnReconnect: true,
    refreshInterval: 30000, // 30 seconds
  })

  return {
    todos: data,
    loading: isLoading,
    error,
  }
}

export function useCreateTodo() {
  const { trigger, isMutating } = useSWRMutation(
    '/api/todos',
    async (url, { arg }: { arg: CreateTodoData }) => {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(arg),
      })
      return response.json()
    },
    {
      onSuccess: (data) => {
        // Optimistic update
        mutate('/api/todos', (todos: Todo[] = []) => [...todos, data], false)
      },
    }
  )

  return {
    createTodo: trigger,
    isCreating: isMutating,
  }
}

Advanced State Management Patterns

State Machines with XState

// machines/todoMachine.ts
import { createMachine, assign } from 'xstate'

interface TodoContext {
  todos: Todo[]
  error: string | null
  filter: 'all' | 'active' | 'completed'
}

type TodoEvent =
  | { type: 'FETCH' }
  | { type: 'ADD_TODO'; text: string }
  | { type: 'TOGGLE_TODO'; id: number }
  | { type: 'DELETE_TODO'; id: number }
  | { type: 'SET_FILTER'; filter: 'all' | 'active' | 'completed' }
  | { type: 'SUCCESS'; data: Todo[] }
  | { type: 'FAILURE'; error: string }

export const todoMachine = createMachine<TodoContext, TodoEvent>({
  id: 'todos',
  initial: 'idle',
  context: {
    todos: [],
    error: null,
    filter: 'all',
  },
  states: {
    idle: {
      on: {
        FETCH: 'loading',
        ADD_TODO: {
          actions: assign({
            todos: (context, event) => [
              ...context.todos,
              {
                id: Date.now(),
                text: event.text,
                completed: false,
                createdAt: new Date(),
              },
            ],
          }),
        },
        TOGGLE_TODO: {
          actions: assign({
            todos: (context, event) =>
              context.todos.map(todo =>
                todo.id === event.id
                  ? { ...todo, completed: !todo.completed }
                  : todo
              ),
          }),
        },
        DELETE_TODO: {
          actions: assign({
            todos: (context, event) =>
              context.todos.filter(todo => todo.id !== event.id),
          }),
        },
        SET_FILTER: {
          actions: assign({
            filter: (_, event) => event.filter,
          }),
        },
      },
    },
    loading: {
      invoke: {
        src: 'fetchTodos',
        onDone: {
          target: 'idle',
          actions: assign({
            todos: (_, event) => event.data,
            error: null,
          }),
        },
        onError: {
          target: 'idle',
          actions: assign({
            error: (_, event) => event.data.message,
          }),
        },
      },
    },
  },
}, {
  services: {
    fetchTodos: () => todosApi.getAll(),
  },
})

Compound State Pattern

// hooks/useCompoundState.ts
import { useReducer, useCallback } from 'react'

interface CompoundState<T> {
  data: T | null
  loading: boolean
  error: string | null
  lastUpdated: number | null
}

type CompoundAction<T> =
  | { type: 'LOADING' }
  | { type: 'SUCCESS'; payload: T }
  | { type: 'ERROR'; payload: string }
  | { type: 'RESET' }

function compoundReducer<T>(
  state: CompoundState<T>,
  action: CompoundAction<T>
): CompoundState<T> {
  switch (action.type) {
    case 'LOADING':
      return {
        ...state,
        loading: true,
        error: null,
      }
    case 'SUCCESS':
      return {
        data: action.payload,
        loading: false,
        error: null,
        lastUpdated: Date.now(),
      }
    case 'ERROR':
      return {
        ...state,
        loading: false,
        error: action.payload,
      }
    case 'RESET':
      return {
        data: null,
        loading: false,
        error: null,
        lastUpdated: null,
      }
    default:
      return state
  }
}

export function useCompoundState<T>() {
  const [state, dispatch] = useReducer(compoundReducer<T>, {
    data: null,
    loading: false,
    error: null,
    lastUpdated: null,
  })

  const execute = useCallback(async (asyncFn: () => Promise<T>) => {
    dispatch({ type: 'LOADING' })
    try {
      const result = await asyncFn()
      dispatch({ type: 'SUCCESS', payload: result })
      return result
    } catch (error) {
      dispatch({ type: 'ERROR', payload: error.message })
      throw error
    }
  }, [])

  const reset = useCallback(() => {
    dispatch({ type: 'RESET' })
  }, [])

  return {
    ...state,
    execute,
    reset,
  }
}

// Usage
function TodoList() {
  const { data: todos, loading, error, execute } = useCompoundState<Todo[]>()

  const fetchTodos = useCallback(() => {
    return execute(() => todosApi.getAll())
  }, [execute])

  useEffect(() => {
    fetchTodos()
  }, [fetchTodos])

  if (loading) return <LoadingSpinner />
  if (error) return <ErrorMessage error={error} />
  if (!todos) return null

  return (
    <div>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </div>
  )
}

Performance Optimization Strategies

Preventing Unnecessary Re-renders

// Memoization strategies
const TodoItem = React.memo(({ todo, onToggle, onDelete }: TodoItemProps) => {
  const handleToggle = useCallback(() => {
    onToggle(todo.id)
  }, [todo.id, onToggle])

  const handleDelete = useCallback(() => {
    onDelete(todo.id)
  }, [todo.id, onDelete])

  return (
    <div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={handleToggle}
      />
      <span>{todo.text}</span>
      <button onClick={handleDelete}>Delete</button>
    </div>
  )
})

// Selector optimization
const selectTodoById = (id: number) => (state: RootState) =>
  state.todos.items.find(todo => todo.id === id)

function TodoItem({ id }: { id: number }) {
  const todo = useAppSelector(selectTodoById(id))
  // Component only re-renders when this specific todo changes
}

// State normalization
interface NormalizedTodosState {
  byId: Record<number, Todo>
  allIds: number[]
  filter: 'all' | 'active' | 'completed'
}

const todosAdapter = createEntityAdapter<Todo>()

const todosSlice = createSlice({
  name: 'todos',
  initialState: todosAdapter.getInitialState({
    filter: 'all' as const,
  }),
  reducers: {
    addTodo: todosAdapter.addOne,
    updateTodo: todosAdapter.updateOne,
    removeTodo: todosAdapter.removeOne,
    setFilter: (state, action) => {
      state.filter = action.payload
    },
  },
})

// Selectors
export const {
  selectAll: selectAllTodos,
  selectById: selectTodoById,
  selectIds: selectTodoIds,
} = todosAdapter.getSelectors((state: RootState) => state.todos)

Virtual Scrolling for Large Lists

// components/VirtualTodoList.tsx
import { FixedSizeList as List } from 'react-window'

interface VirtualTodoListProps {
  todos: Todo[]
  onToggle: (id: number) => void
  onDelete: (id: number) => void
}

const TodoRow = ({ index, style, data }: any) => {
  const { todos, onToggle, onDelete } = data
  const todo = todos[index]

  return (
    <div style={style}>
      <TodoItem
        todo={todo}
        onToggle={onToggle}
        onDelete={onDelete}
      />
    </div>
  )
}

export function VirtualTodoList({ todos, onToggle, onDelete }: VirtualTodoListProps) {
  return (
    <List
      height={600}
      itemCount={todos.length}
      itemSize={60}
      itemData={{ todos, onToggle, onDelete }}
    >
      {TodoRow}
    </List>
  )
}

Testing State Management

Testing Redux Slices

// __tests__/todosSlice.test.ts
import { configureStore } from '@reduxjs/toolkit'
import { todosSlice, addTodo, toggleTodo, setFilter } from '../store/slices/todosSlice'

describe('todosSlice', () => {
  let store: ReturnType<typeof configureStore>

  beforeEach(() => {
    store = configureStore({
      reducer: {
        todos: todosSlice.reducer,
      },
    })
  })

  it('should add a todo', () => {
    const todoText = 'Test todo'
    store.dispatch(addTodo(todoText))

    const state = store.getState().todos
    expect(state.items).toHaveLength(1)
    expect(state.items[0].text).toBe(todoText)
    expect(state.items[0].completed).toBe(false)
  })

  it('should toggle a todo', () => {
    store.dispatch(addTodo('Test todo'))
    const todoId = store.getState().todos.items[0].id

    store.dispatch(toggleTodo(todoId))

    const state = store.getState().todos
    expect(state.items[0].completed).toBe(true)
  })

  it('should filter todos correctly', () => {
    store.dispatch(addTodo('Todo 1'))
    store.dispatch(addTodo('Todo 2'))
    store.dispatch(toggleTodo(store.getState().todos.items[0].id))

    store.dispatch(setFilter('completed'))
    const state = store.getState().todos
    expect(state.filter).toBe('completed')
  })
})

Testing Custom Hooks

// __tests__/useTodos.test.ts
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useTodos, useCreateTodo } from '../hooks/useTodos'

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  })

  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

describe('useTodos', () => {
  it('should fetch todos', async () => {
    const { result, waitFor } = renderHook(() => useTodos(), {
      wrapper: createWrapper(),
    })

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true)
    })

    expect(result.current.data).toBeDefined()
  })

  it('should create a todo', async () => {
    const { result } = renderHook(() => useCreateTodo(), {
      wrapper: createWrapper(),
    })

    await act(async () => {
      result.current.mutate({ text: 'New todo' })
    })

    expect(result.current.isSuccess).toBe(true)
  })
})

Conclusion

State management in modern React applications is a nuanced discipline that requires understanding the trade-offs between different approaches. The key is choosing the right tool for the right job:

  • Built-in React state for local component state and simple shared state
  • Redux Toolkit for complex global state with time-travel debugging needs
  • Zustand for lightweight global state with minimal boilerplate
  • Jotai for atomic, bottom-up state management
  • TanStack Query/SWR for server state management
  • XState for complex state machines and workflows

The future of React state management continues to evolve with new patterns like Server Components, which blur the lines between client and server state. However, the fundamental principles remain the same: keep state as local as possible, use the right abstraction for your use case, and prioritize developer experience and maintainability.

Remember that state management is not just about choosing a library—it's about designing your application's data flow in a way that scales with your team and requirements. Start simple, measure performance, and evolve your approach as your application grows in complexity.

Resources


Mastering state management is essential for building scalable React applications. The patterns and techniques covered in this guide will serve as a foundation for creating maintainable, performant applications that can grow with your needs.

TD

About Tridip Dutta

Creative Developer passionate about creating innovative digital experiences and exploring AI. I love sharing knowledge to help developers build better apps.