Redux: The Global State Beast
Complete Redux tutorial covering actions, reducers, store, and middleware. Learn Redux DevTools for time-travel debugging, async actions with Redux Thunk, selectors with Reselect, and real-world patterns. Understand when Redux makes sense and how to structure large React applications.
When Context Wasn’t Enough
After learning Context API, I felt powerful. No more prop drilling! Global state without external libraries!
Then my app grew.
I had:
- User authentication state
- Shopping cart state
- UI state (modals, sidebars, notifications)
- Cached API data
- Form state across multiple steps
My AppProvider started looking like this:
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const [notifications, setNotifications] = useState([]);
const [theme, setTheme] = useState('light');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [modalState, setModalState] = useState({ open: false, content: null });
const [products, setProducts] = useState([]);
const [orders, setOrders] = useState([]);
// ... 15 more useState calls
// Functions to update all of these...
const addToCart = (product) => { /* ... */ };
const removeFromCart = (productId) => { /* ... */ };
const updateQuantity = (productId, quantity) => { /* ... */ };
const login = async (credentials) => { /* ... */ };
const logout = () => { /* ... */ };
// ... 30 more functions
return (
<AuthContext.Provider value={{ user, login, logout }}>
<CartContext.Provider value={{ cart, addToCart, removeFromCart, updateQuantity }}>
<UIContext.Provider value={{ sidebarOpen, setSidebarOpen, modalState, setModalState }}>
<NotificationContext.Provider value={{ notifications, addNotification, removeNotification }}>
{/* The pyramid of doom */}
{children}
</NotificationContext.Provider>
</UIContext.Provider>
</CartContext.Provider>
</AuthContext.Provider>
);
}
It was becoming unmanageable. State updates were scattered. Debugging was a nightmare. I couldn’t track why something changed.
Every senior developer I asked said the same thing: “Have you tried Redux?”
What Is Redux, Actually?
Redux is a predictable state container. That sounds abstract, so let me break it down:
- Single source of truth - All state lives in one object called the “store”
- State is read-only - You can’t directly modify state; you dispatch “actions”
- Changes via pure functions - “Reducers” take current state + action and return new state
// The entire app state in one place
const store = {
user: { name: 'Saurav', loggedIn: true },
cart: [{ id: 1, name: 'Laptop', quantity: 1 }],
notifications: [],
ui: { sidebarOpen: false }
};
// Want to add to cart? Dispatch an action
dispatch({ type: 'ADD_TO_CART', payload: { id: 2, name: 'Mouse' } });
// A reducer handles the action
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_TO_CART':
return [...state, { ...action.payload, quantity: 1 }];
default:
return state;
}
}
The key insight: every state change is explicit and traceable. No more wondering “what updated this?”
The Infamous Boilerplate
Let me show you what “Redux boilerplate” actually looks like. Setting up a simple counter:
Step 1: Action Types
// actionTypes.js
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
Step 2: Action Creators
// actions/counterActions.js
import { INCREMENT, DECREMENT, RESET } from '../actionTypes';
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
export const reset = () => ({ type: RESET });
Step 3: Reducer
// reducers/counterReducer.js
import { INCREMENT, DECREMENT, RESET } from '../actionTypes';
const initialState = { count: 0 };
export function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 };
case DECREMENT:
return { ...state, count: state.count - 1 };
case RESET:
return { ...state, count: 0 };
default:
return state;
}
}
Step 4: Combine Reducers
// reducers/index.js
import { combineReducers } from 'redux';
import { counterReducer } from './counterReducer';
import { userReducer } from './userReducer';
import { cartReducer } from './cartReducer';
export const rootReducer = combineReducers({
counter: counterReducer,
user: userReducer,
cart: cartReducer,
});
Step 5: Create Store
// store.js
import { createStore } from 'redux';
import { rootReducer } from './reducers';
export const store = createStore(rootReducer);
Step 6: Provider
// index.js
import { Provider } from 'react-redux';
import { store } from './store';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
Step 7: Connect Component (the old way)
// Counter.js
import { connect } from 'react-redux';
import { increment, decrement, reset } from './actions/counterActions';
function Counter({ count, increment, decrement, reset }) {
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
const mapStateToProps = (state) => ({
count: state.counter.count,
});
const mapDispatchToProps = {
increment,
decrement,
reset,
};
export default connect(mapStateToProps, mapDispatchToProps)(Counter);
For a counter. A COUNTER. Seven steps across multiple files.
I understand why people complained about boilerplate now.
The Redux Hooks Era
Just when I was drowning in mapStateToProps, Redux got hooks. Life got better:
// Counter.js - with hooks
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, reset } from './actions/counterActions';
function Counter() {
const count = useSelector(state => state.counter.count);
const dispatch = useDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(decrement())}>-</button>
<button onClick={() => dispatch(reset())}>Reset</button>
</div>
);
}
export default Counter;
No more connect. No more HOC wrapping. Just hooks. useSelector to read state, useDispatch to dispatch actions.
The Real Power: DevTools
Here’s what made the boilerplate worth it: Redux DevTools.
Install the browser extension. Open your app. Suddenly you can:
- See every action that was dispatched
- Inspect state before and after each action
- Time travel - replay actions, jump to any point in history
- Export/import state - reproduce bugs exactly
I was debugging a complex checkout flow. Users reported random cart items disappearing. With DevTools, I could see:
Action: REMOVE_FROM_CART
Payload: { productId: 5 }
State Before: cart: [{ id: 3 }, { id: 5 }, { id: 7 }]
State After: cart: [{ id: 3 }, { id: 7 }]
Found the bug in 5 minutes. Without DevTools? Could have taken hours.
Async Actions: The Middleware Problem
Redux reducers must be pure functions. No side effects. No API calls. So how do you fetch data?
Middleware.
Redux Thunk
The most common solution. Action creators can return functions instead of objects:
// Without thunk - just returns an object
const addTodo = (text) => ({
type: 'ADD_TODO',
payload: { text }
});
// With thunk - returns a function that can do async stuff
const fetchUser = (userId) => {
return async (dispatch) => {
dispatch({ type: 'FETCH_USER_START' });
try {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'FETCH_USER_ERROR', payload: error.message });
}
};
};
Now you need three action types for every async operation:
FETCH_USER_START(set loading state)FETCH_USER_SUCCESS(store data)FETCH_USER_ERROR(store error)
And the reducer:
function userReducer(state = initialState, action) {
switch (action.type) {
case 'FETCH_USER_START':
return { ...state, loading: true, error: null };
case 'FETCH_USER_SUCCESS':
return { ...state, loading: false, user: action.payload };
case 'FETCH_USER_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
Triple the action types. Triple the reducer cases. For every API call.
The boilerplate intensifies.
Redux Saga (The Complex Alternative)
Some teams used Redux Saga instead of Thunk. It uses generators for async flows:
import { call, put, takeEvery } from 'redux-saga/effects';
function* fetchUserSaga(action) {
try {
yield put({ type: 'FETCH_USER_START' });
const user = yield call(fetch, `/api/users/${action.payload}`);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
yield put({ type: 'FETCH_USER_ERROR', payload: error.message });
}
}
function* watchFetchUser() {
yield takeEvery('FETCH_USER_REQUESTED', fetchUserSaga);
}
More powerful for complex flows (cancellation, racing, debouncing). Also more to learn. I stuck with Thunk for most projects.
My Folder Structure
After much trial and error, here’s what worked:
src/
├── store/
│ ├── index.js # Store configuration
│ ├── rootReducer.js # Combine all reducers
│ └── features/
│ ├── auth/
│ │ ├── authSlice.js # Actions + reducer together
│ │ └── authSelectors.js
│ ├── cart/
│ │ ├── cartSlice.js
│ │ └── cartSelectors.js
│ └── ui/
│ ├── uiSlice.js
│ └── uiSelectors.js
Grouping by feature instead of by type (all actions together, all reducers together) made navigation so much easier.
Selectors: Deriving State
Raw state access gets messy:
// Scattered throughout components
const cartItems = useSelector(state => state.cart.items);
const cartTotal = useSelector(state =>
state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const itemCount = useSelector(state =>
state.cart.items.reduce((sum, item) => sum + item.quantity, 0)
);
Selectors centralize this logic:
// cartSelectors.js
export const selectCartItems = state => state.cart.items;
export const selectCartTotal = state =>
state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectCartItemCount = state =>
state.cart.items.reduce((sum, item) => sum + item.quantity, 0);
// In components
const total = useSelector(selectCartTotal);
const itemCount = useSelector(selectCartItemCount);
Change the state shape? Update one selector. Not 15 components.
Reselect for Memoization
Computed selectors can be expensive. reselect memoizes them:
import { createSelector } from 'reselect';
const selectCartItems = state => state.cart.items;
const selectTaxRate = state => state.settings.taxRate;
// Only recalculates when items or taxRate change
export const selectCartTotalWithTax = createSelector(
[selectCartItems, selectTaxRate],
(items, taxRate) => {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
);
When Redux Made Sense
After months of using Redux, I understood when it shines:
Redux is great for:
- Large apps with complex state interactions
- When multiple parts of the app need the same data
- When you need to track state changes (debugging, logging)
- Teams where predictability matters more than brevity
- Apps with complex async flows
Redux is overkill for:
- Small to medium apps
- State that’s only used in one place
- Simple CRUD apps
- Prototypes and MVPs
What I Wish I’d Known Earlier
-
You don’t have to put everything in Redux. Form state? Local. Animation state? Local. Only truly global state belongs in Redux.
-
Normalize your state. Store entities by ID, not nested:
// ❌ Nested (hard to update)
{ posts: [{ id: 1, comments: [{ id: 1, text: '...' }] }] }
// ✅ Normalized (easy to update)
{
posts: { 1: { id: 1, commentIds: [1] } },
comments: { 1: { id: 1, text: '...' } }
}
-
Selectors are not optional. Use them from day one. Future you will thank present you.
-
DevTools are the killer feature. The boilerplate is worth it just for time-travel debugging.
-
There’s a better way coming. While I was learning Redux, something called “Redux Toolkit” was gaining traction. It promised to solve the boilerplate problem…
The Journey Continues
Redux taught me the value of predictable state management. Actions, reducers, immutable updates—these patterns transcend Redux itself.
But the boilerplate was real. The learning curve was steep. I heard whispers of “Redux Toolkit” that would fix everything.
Time to see what the fuss was about.
P.S. — The first time I used Redux DevTools to time-travel debug a production issue, I felt like a wizard. A colleague watched me replay 50 actions to find a bug and said “what kind of sorcery is this?” That moment made all the boilerplate worth it.
Saurav Sitaula
Software Architect • Nepal