React State Management: From useState to Redux Toolkit

Managing state in React is a core part of building scalable and maintainable applications. While React’s built-in hooks like useState provide a simple and straightforward way to handle local component state, real-world applications often require more sophisticated solutions to handle shared state, avoid prop drilling, and keep the codebase predictable. In this post, we’ll explore the evolution of state management in React, starting with useState and ending with Redux Toolkit.
The Basics: useState
React’s useState hook is often the first state management tool developers learn. It allows you to store and update local state in functional components:
1import { useState } from 'react'; 2 3function Counter() { 4 const [count, setCount] = useState<number>(0); 5 6 return ( 7 <div> 8 <p>Count: {count}</p> 9 <button onClick={() => setCount(count + 1)}>Increment</button> 10 </div> 11 ); 12}
useState works beautifully for state that is local to a component. It’s simple, predictable, and built into React, making it perfect for isolated UI logic like toggles, input fields, and small counters.
The Problem: Prop Drilling and Shared State
As applications grow, state often needs to be shared between components at different levels of the component tree. Passing state and setters through multiple layers of props—commonly called prop drilling—can quickly become cumbersome:
1<Parent> 2 <Child> 3 <Grandchild count={count} setCount={setCount} /> 4 </Child> 5</Parent>
While this works, it adds boilerplate and tightly couples components. Refactoring becomes painful, and adding new layers of nested components can force you to rewire the props chain.
Context API: React’s Built-In Solution
To address prop drilling, React introduced the Context API, which allows you to provide state at a higher level and consume it anywhere in the tree:
1import { createContext, useState, useContext, ReactNode } from 'react'; 2 3interface CountContextType { 4 count: number; 5 setCount: (value: number) => void; 6} 7 8const CountContext = createContext<CountContextType | undefined>(undefined); 9 10function CountProvider({ children }: { children: ReactNode }) { 11 const [count, setCount] = useState<number>(0); 12 return ( 13 <CountContext.Provider value={{ count, setCount }}> 14 {children} 15 </CountContext.Provider> 16 ); 17} 18 19function useCount() { 20 const context = useContext(CountContext); 21 if (!context) throw new Error('useCount must be used within CountProvider'); 22 return context; 23} 24 25function Grandchild() { 26 const { count, setCount } = useCount(); 27 return ( 28 <div> 29 <p>Count: {count}</p> 30 <button onClick={() => setCount(count + 1)}>Increment</button> 31 </div> 32 ); 33}
Context is great for moderately complex applications, especially for theme, authentication state, or small shared data. But frequent updates can trigger unnecessary re-renders, and managing multiple contexts can become cumbersome in very large applications.
State Management Libraries: Why They Exist
When state sharing becomes more complex—especially with global state or async data fetching—state management libraries provide solutions that React alone struggles with. Libraries like Zustand, Recoil, and MobX allow you to:
- Maintain centralized state outside of components
- Reduce or eliminate
prop drilling - Handle async logic, caching, and middleware
- Integrate with DevTools for debugging
These libraries exist because in real-world applications, React’s hooks and Context API start showing limitations in performance, maintainability, and predictability.
Redux and Redux Toolkit: Modern Redux
Among state management libraries, Redux is the most widely used and battle-tested. Originally, Redux had a lot of boilerplate, which intimidated many developers. Today, Redux Toolkit (RTK) simplifies Redux and provides modern patterns for scalable apps:
- Simplified store setup
- Immutable updates via
createSlice - Async data fetching, caching, and automatic re-fetching via RTK Query
- DevTools integration
Here’s a simple counter example using Redux Toolkit:
1import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit'; 2import { Provider, useDispatch, useSelector } from 'react-redux'; 3 4const counterSlice = createSlice({ 5 name: 'counter', 6 initialState: 0, 7 reducers: { 8 increment: (state) => state + 1, 9 decrement: (state) => state - 1, 10 addBy: (state, action: PayloadAction<number>) => state + action.payload, 11 }, 12}); 13 14const store = configureStore({ reducer: counterSlice.reducer }); 15 16function Counter() { 17 const count = useSelector((state: number) => state); 18 const dispatch = useDispatch(); 19 20 return ( 21 <div> 22 <p>Count: {count}</p> 23 <button onClick={() => dispatch(counterSlice.actions.increment())}>Increment</button> 24 <button onClick={() => dispatch(counterSlice.actions.addBy(5))}>Add 5</button> 25 </div> 26 ); 27} 28 29function App() { 30 return ( 31 <Provider store={store}> 32 <Counter /> 33 </Provider> 34 ); 35}
RTK Query Features
RTK Query is a powerful addition to Redux Toolkit that handles server state efficiently. Key features include:
- Data fetching with caching out of the box
- Automatic re-fetching on cache invalidation or parameter changes
- Optimistic updates for smoother UX
- Auto-generated hooks for queries and mutations
- Built-in TypeScript support for request and response types
Using RTK Query, you don’t need to manually manage loading states, caching, or error handling—it’s all integrated and type-safe.
Conclusion
State management in React evolves as your app grows. Start simple with useState for local state. Move to Context API when sharing state becomes necessary. And for large, complex apps with global state, async data, and caching needs, Redux Toolkit with RTK Query provides a modern, maintainable, and scalable solution. Understanding these tools ensures your React applications remain performant and predictable.