What is Redux?
Redux is a predictable state container for JavaScript apps that centralizes all state changes through pure reducer functions responding to plain action objects.
Theory
TL;DR
- Redux is like a single company ledger: every change (action) gets recorded by accountants (reducers) who produce a new page (state) without erasing the old one
- One global store instead of scattered local state across components
- Three moving parts: store (holds state), actions (describe events), reducers (pure update functions)
- Use it when state logic repeats across 5+ components or you need time-travel debugging
- Redux Toolkit is the modern way to write Redux, cutting roughly 70% of the boilerplate
Quick example
import { createStore } from 'redux';
const reducer = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO': return [...state, action.payload]; // Immutable update
default: return state;
}
};
const store = createStore(reducer);
store.dispatch({ type: 'ADD_TODO', payload: 'Buy milk' });
console.log(store.getState()); // ['Buy milk']
store.dispatch({ type: 'ADD_TODO', payload: 'Walk dog' });
console.log(store.getState()); // ['Buy milk', 'Walk dog']The action describes what happened. The reducer decides what that means for the state. The store holds the result.
Key difference
Local component state is scattered across the tree. Any component that needs shared data must receive it through props or a context, which gets messy fast. Redux puts everything in one object tree, and the only way to change anything in it is to dispatch an action. That single constraint is what makes large apps debuggable.
When to use Redux
- Small app, fewer than 5 components sharing state - skip it. React Context or useState is enough.
- Medium app with repeated state logic - Redux Toolkit with slices.
- Large app that needs devtools or time-travel debugging - full Redux with middleware.
- Server-side or non-React JS app - Redux core works without React bindings.
- State under 10KB and simple - consider Zustand, it is lighter.
How Redux works internally
When you call store.dispatch(action), Redux passes the action through any registered middleware (thunks, loggers), then hands it to the root reducer. The root reducer calls combineReducers, which passes the action to each child reducer. Each child returns a new slice of state. Redux shallow-merges the slices into a new state object and notifies all subscribers. React's useSelector compares the new state with the previous one using reference equality, and re-renders only the components whose selected slice actually changed.
Pure functions make this fast. Immutable updates allow React.memo and reselect to skip unnecessary renders with a simple reference check.
Common mistakes
Mutating state in a reducer
// Wrong - mutates the shared array
function badReducer(state = [], action) {
if (action.type === 'ADD') state.push(action.payload); // Direct mutation!
return state;
}
// Correct - returns a new array
function goodReducer(state = [], action) {
if (action.type === 'ADD') return [...state, action.payload];
return state;
}Redux shares state references between renders. If you mutate, all subscribers see the change immediately, bypassing the reducer cycle and breaking the whole model.
Dispatching inside render
// Wrong - runs on every render, causes an infinite loop
function BadComponent() {
dispatch(fetchData());
return <div>...</div>;
}
// Correct - runs once on mount
function GoodComponent() {
useEffect(() => { dispatch(fetchData()); }, []);
return <div>...</div>;
}This is one of the most common Redux bugs in production. I have seen it cause hundreds of duplicate API calls in a single session before anyone noticed.
Ignoring Redux Toolkit
Writing vanilla Redux with createStore and manual combineReducers in 2024 adds a lot of boilerplate for no real gain. Redux Toolkit gives you Immer (mutative syntax that stays immutable), auto-generated action creators, and createAsyncThunk for async flows. The Redux team recommends Toolkit for all new projects.
Race conditions in thunks
store.dispatch(fetchUser(1)); // dispatched first, responds later
store.dispatch(fetchUser(2)); // dispatched second, responds first
// Without protection, the slower request overwrites the faster oneFix: store the requestId when the request starts, then check in fulfilled whether it matches. If not, skip the update.
Over-normalizing small state
Nesting { entities: { users: { 1: { name: 'Bob' } } } } for a single user adds devtools clutter and boilerplate with no benefit. Keep state flat until you have more than 10 related items.
Real-world usage
- React / Next.js - global state for auth, cart, notifications (Shopify Hydrogen uses RTK Query)
- Electron apps - offline-first state sync (Discord desktop is a well-known example)
- Node.js with WebSockets - shared session state across connections
- Vanilla JS - game state for deterministic replays (common in Phaser-based games)
- Alternatives - Zustand for apps under 500 lines, Jotai for atomic state, TanStack Query when most state is server data
Follow-up questions
Q: Walk me through the Redux flow from dispatch to a React re-render.
A: Action hits dispatch, passes through middleware (e.g. thunk resolves async work), reaches the root reducer, which calls child reducers via combineReducers, each returning a new slice. Redux builds a new state object, notifies subscribers. React's useSelector runs the selector, compares by reference, and re-renders the component if the result changed.
Q: Why do reducers have to be pure functions?
A: Pure functions return the same output for the same input and have no side effects. That property makes time-travel debugging possible: replay any sequence of actions and you get the same state. It also lets Redux use shallow equality checks for performance and makes unit testing straightforward.
Q: What does Redux Toolkit add over vanilla Redux?
A: Immer integration (write mutative syntax, get immutable updates), createSlice (auto-generates action creators and types), configureStore (adds Redux DevTools and thunk middleware by default), createAsyncThunk (handles pending/fulfilled/rejected states), and RTK Query (data fetching with caching). It cuts roughly 70% of boilerplate.
Q: How would you implement combineReducers yourself?
A:
function combineReducers(reducers) {
return (state = {}, action) => {
return Object.keys(reducers).reduce((nextState, key) => {
nextState[key] = reducers[key](state[key], action);
return nextState;
}, {});
};
}Each key gets its own slice of state. The root reducer calls each child and assembles the result.
Q: How do you handle race conditions in async thunks?
A: Redux Toolkit's createAsyncThunk attaches a requestId to each dispatched thunk. Store the latest requestId in state when the request starts. In the fulfilled case, check: if (action.meta.requestId !== state.currentRequestId) return;. This drops responses from stale requests.
Examples
Basic Redux flow
import { createStore } from 'redux';
const reducer = (state = { count: 0 }, action) => {
switch (action.type) {
case 'INCREMENT': return { count: state.count + 1 };
case 'DECREMENT': return { count: state.count - 1 };
default: return state;
}
};
const store = createStore(reducer);
store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'INCREMENT' }); // { count: 1 }
store.dispatch({ type: 'INCREMENT' }); // { count: 2 }
store.dispatch({ type: 'DECREMENT' }); // { count: 1 }The store holds one object. Every dispatch goes through the reducer. State never changes in place.
Todo list with Redux Toolkit
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action) => { state.push(action.payload); }, // Immer handles immutability
toggleTodo: (state, action) => { state[action.payload].completed = true; }
}
});
const store = configureStore({ reducer: { todos: todosSlice.reducer } });
function TodoList() {
const todos = useSelector(state => state.todos);
const dispatch = useDispatch();
return (
<ul>
{todos.map((todo, i) => (
<li key={i} onClick={() => dispatch(todosSlice.actions.toggleTodo(i))}>
{todo.text}
</li>
))}
<button onClick={() => dispatch(todosSlice.actions.addTodo({ text: 'New task', completed: false }))}>
Add
</button>
</ul>
);
}createSlice generates action creators automatically. Immer intercepts the state.push(...) call inside the reducer and applies it as an immutable update behind the scenes.
Async thunk with race condition handling
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
const fetchUser = createAsyncThunk('user/fetch', async (id) => {
const response = await fetch(`/api/user/${id}`);
return response.json();
});
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, currentRequestId: null },
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state, action) => {
state.loading = true;
state.currentRequestId = action.meta.requestId; // Track the latest request
})
.addCase(fetchUser.fulfilled, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return; // Drop stale responses
state.data = action.payload;
state.loading = false;
});
}
});Without the requestId check, a slow first request can override the result of a faster second one. This pattern prevents that overwrite.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.