Modern Redux interviews focus less on hand-written action constants and more on choosing the right boundary between local state, client state, and server data. Candidates should be able to explain Redux Toolkit's defaults, selector identity, async cancellation, and when Redux is unnecessary.
These 26 questions cover current Redux Toolkit, React-Redux, RTK Query, and the architectural trade-offs behind them.
Table of Contents
- Redux Core Concepts Questions
- Redux Data Flow Questions
- Redux Toolkit Questions
- Async and Middleware Questions
- Performance Optimization Questions
- State Architecture Questions
- Redux Alternatives Questions
- Implementation Questions
- Quick Reference
Redux Core Concepts Questions
Understanding Redux's fundamental principles is essential for any interview involving state management.
What is Redux and why does it exist?
Redux is a predictable state container for JavaScript applications. It centralizes the state you deliberately place in one store and uses unidirectional data flow: code dispatches actions that describe events, and reducers calculate the next state. Local component state does not need to move into Redux.
Redux solves the problem of managing complex state across many components. Without it, you face prop drilling through multiple component layers or managing state in multiple locations, leading to inconsistencies and hard-to-trace bugs.
The key architectural constraints Redux enforces are what make it powerful. Every state change follows the same path: action → reducer → new state → UI. This makes it easy to understand how data flows, implement features like undo/redo and time-travel debugging, and maintain consistent state as your application scales.
What are the three core principles of Redux?
Redux is built on three fundamental principles that work together to create predictable state management. Understanding these principles helps you debug issues and explains why Redux works the way it does.
The first principle is Single Source of Truth—the global state managed by Redux is represented by one object tree within one store. This does not mean every form field, hover state, server cache, or component detail belongs there.
The second principle is State is Read-Only—the only way to change state is to dispatch an action, an object describing what happened. You cannot directly modify the state object. This ensures all changes are centralized, sequential, and traceable.
The third principle is Changes Made with Pure Functions—reducers must be pure functions that calculate next state from previous state and action. Given the same inputs, they always return the same output with no side effects. This predictability is what makes Redux's state management reliable.
// Principle 1: Single source of truth
const store = {
user: { name: 'John', isAuthenticated: true },
posts: [],
ui: { theme: 'dark', sidebarOpen: false }
};
// State intentionally managed by Redux lives in this tree
// Principle 2: State is read-only
// ❌ WRONG: Direct mutation
store.user.name = 'Jane'; // Never do this!
// ✅ CORRECT: Dispatch action to describe change
dispatch({
type: 'user/updateName',
payload: 'Jane'
});
// Principle 3: Pure function reducer
function userReducer(state = { name: '', isAuthenticated: false }, action) {
switch (action.type) {
case 'user/updateName':
// Return NEW object, don't mutate
return { ...state, name: action.payload };
default:
return state;
}
}Why must Redux reducers be pure functions?
Reducers must be pure functions because purity enables Redux's most valuable features: time-travel debugging, predictable testing, and reliable state reconstruction. A pure function always produces the same output given the same inputs and has no side effects.
When reducers are pure, you can replay any sequence of actions and always arrive at the same state. This makes debugging straightforward—you can step backward and forward through state changes, inspect each action's effect, and reproduce bugs reliably.
Pure reducers also make testing simple. You pass in a state and action, assert on the returned state. No mocking, no setup, no cleanup. The predictability of pure functions is the foundation of Redux's reliability.
What happens if you mutate state directly in a reducer?
Mutating existing state in a hand-written reducer breaks Redux's immutable-update contract. The store still notifies subscribers after dispatch, but selectors that receive the same object reference can consider their result unchanged, and DevTools, memoization, and replay assumptions can fail.
Redux Toolkit case reducers are the important exception in syntax, not semantics: createSlice passes an Immer draft, so assignments to the draft produce an immutable next state. Never mutate objects from the existing state outside an Immer-powered reducer.
// ❌ WRONG: Mutating state directly
function userReducer(state, action) {
if (action.type === 'UPDATE_NAME') {
state.name = action.payload; // Mutation!
return state; // Same reference - React won't detect change
}
return state;
}
// ✅ CORRECT: Return new object
function userReducer(state, action) {
if (action.type === 'UPDATE_NAME') {
return { ...state, name: action.payload }; // New reference
}
return state;
}Redux Data Flow Questions
Understanding how data moves through Redux is crucial for debugging and architectural decisions.
How does the Redux data flow work?
Redux follows a strict unidirectional cycle that makes state changes predictable and traceable. Understanding this flow helps you debug issues and explains why Redux requires certain patterns.
The flow starts when something happens—a user clicks a button, data arrives from an API, or a timer fires. This triggers an action dispatch, sending an action object to the store. The store calls the root reducer with current state and the action. The reducer examines the action type, calculates changes, and returns a completely new state object.
The store keeps the reducer result and notifies subscribers. If no slice changed, a root reducer may return the previous root reference. React-Redux runs relevant subscription logic; a useSelector component re-renders when the selected result changes according to its equality function.
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { useSelector, useDispatch, Provider } from 'react-redux';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; }
}
});
const store = configureStore({
reducer: { counter: counterSlice.reducer }
});
function Counter() {
// Step 5: Subscribe to store, get current state
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
const handleIncrement = () => {
// Step 1: User interaction triggers action dispatch
dispatch(counterSlice.actions.increment());
// Steps 2-4 happen automatically:
// Store calls reducer → reducer calculates next state → subscribers run
};
// Step 6: Component re-renders with updated state
return (
<div>
<span>{count}</span>
<button onClick={handleIncrement}>+</button>
</div>
);
}What happens when multiple reducers exist?
When multiple reducers exist, each reducer receives every action but only handles those relevant to its slice. The root reducer—created by combineReducers or Redux Toolkit's configureStore—coordinates this process by calling each slice reducer and merging their results into a single state object.
This design allows you to split your reducer logic by domain (users, posts, ui) while maintaining a single state tree. Each slice reducer is responsible only for its portion of state and can ignore actions meant for other slices.
// Each slice only handles its own state
const usersSlice = createSlice({
name: 'users',
initialState: [],
reducers: {
addUser: (state, action) => { state.push(action.payload); }
}
});
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
addPost: (state, action) => { state.push(action.payload); }
}
});
// configureStore combines them
const store = configureStore({
reducer: {
users: usersSlice.reducer, // Only handles users/* actions
posts: postsSlice.reducer // Only handles posts/* actions
}
});
// When dispatch(addUser({...})) is called:
// - usersSlice.reducer receives it, updates users state
// - postsSlice.reducer receives it, returns unchanged posts state
// - Store merges both into { users: [...], posts: [...] }Redux Toolkit Questions
Redux Toolkit is the modern, recommended way to write Redux code.
What is Redux Toolkit and why does it exist?
Redux Toolkit (RTK) is the official, opinionated toolset for Redux development. It exists because classic Redux required too much boilerplate—separate files for action types, action creators, and reducers, plus manual immutable update logic that was error-prone.
RTK solves this with several key APIs. configureStore sets up the store with good defaults including Redux DevTools and middleware. createSlice generates action creators and action types from reducer functions. Immer integration lets you write "mutating" logic that's automatically converted to immutable updates.
// Classic Redux (verbose, error-prone)
const INCREMENT = 'counter/increment';
const DECREMENT = 'counter/decrement';
function increment() {
return { type: INCREMENT };
}
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case INCREMENT:
return { ...state, value: state.value + 1 };
case DECREMENT:
return { ...state, value: state.value - 1 };
default:
return state;
}
}
// Redux Toolkit (concise, safe)
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; }, // Immer handles immutability
decrement: (state) => { state.value -= 1; }
}
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;How does Immer work in Redux Toolkit?
Immer is a library that lets you write code that appears to mutate state, but actually produces immutable updates. When you modify a "draft" state in a Redux Toolkit reducer, Immer tracks those changes and produces a new immutable state object.
Think of it like editing a photocopy. You can scribble all over the copy, and when you're done, Immer gives you a clean new original that incorporates your changes—leaving the actual original untouched.
// What you write (looks like mutation)
const userSlice = createSlice({
name: 'user',
initialState: {
profile: { name: '', settings: { theme: 'dark' } }
},
reducers: {
updateTheme: (state, action) => {
state.profile.settings.theme = action.payload; // Looks like mutation!
}
}
});
// What Immer produces (immutable update)
// Equivalent to:
function updateThemeManual(state, action) {
return {
...state,
profile: {
...state.profile,
settings: {
...state.profile.settings,
theme: action.payload
}
}
};
}The key insight is that deeply nested immutable updates are verbose and error-prone manually. Immer eliminates that entire class of bugs while keeping code readable.
Can you return a value from an Immer-powered reducer?
Yes—if you return a new value instead of modifying draft, Immer uses your returned value. This is useful when you want to replace state entirely rather than modify it.
Return a new value when you want to replace the slice entirely, such as resetting it. A case reducer must either mutate the draft or return a replacement value—doing both is an Immer error. Returning undefined means “keep the draft result”, so assigning state = newValue does not replace state; explicitly return newValue.
const userSlice = createSlice({
name: 'user',
initialState: { name: '', email: '', isLoggedIn: false },
reducers: {
// Modify draft - Immer produces immutable update
updateName: (state, action) => {
state.name = action.payload;
},
// Return new value - replaces state entirely
reset: () => {
return { name: '', email: '', isLoggedIn: false };
},
// Or simply return initialState
logout: () => initialState
}
});How does createSlice auto-generate action types?
createSlice combines the slice name with the reducer key to generate action types automatically. This eliminates the need to manually define action type constants while ensuring unique, descriptive action types.
For a slice with name: 'user' and a reducer called login, the generated action type is 'user/login'. This naming convention makes it easy to identify which slice an action belongs to when debugging with Redux DevTools.
const userSlice = createSlice({
name: 'user',
initialState: { name: '', isLoggedIn: false },
reducers: {
login: (state, action) => {
state.name = action.payload;
state.isLoggedIn = true;
},
logout: (state) => {
state.isLoggedIn = false;
}
}
});
// Auto-generated action creators
console.log(userSlice.actions.login('John'));
// { type: 'user/login', payload: 'John' }
console.log(userSlice.actions.logout());
// { type: 'user/logout' }What is the difference between extraReducers and reducers in createSlice?
The reducers field generates actions automatically—each reducer function becomes an action creator. The extraReducers field responds to actions defined elsewhere, such as actions from other slices or createAsyncThunk.
Use reducers for actions that belong to this slice. Use extraReducers when you need to respond to actions you don't own—like updating a loading state when an async thunk starts, or clearing user data when another slice's logout action fires.
// Actions defined in this slice
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false },
reducers: {
clearUser: (state) => {
state.data = null;
}
},
// Responding to actions from elsewhere
extraReducers: (builder) => {
builder
// Actions from createAsyncThunk
.addCase(fetchUser.pending, (state) => {
state.loading = true;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
// Actions from other slices
.addCase(authSlice.actions.logout, (state) => {
state.data = null;
});
}
});Async and Middleware Questions
Handling asynchronous operations is a key part of Redux development.
What is createAsyncThunk and how do you use it?
createAsyncThunk abstracts a common request lifecycle. It generates a thunk action creator that dispatches pending, fulfilled, and rejected actions. It is useful for imperative async logic, but RTK Query is normally a better fit for cached server-data fetching.
This eliminates the boilerplate of manually dispatching loading/success/error actions while providing a consistent pattern for handling async operations across your application.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Define the async thunk
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (userId, { signal, rejectWithValue }) => {
const response = await fetch(`/api/users/${userId}`, { signal });
if (!response.ok) {
return rejectWithValue(`HTTP ${response.status}`);
}
return response.json();
}
);
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
if (!action.meta.aborted) {
state.error = action.payload ?? action.error.message;
}
});
}
});
// Usage in component
function UserProfile({ userId }) {
const dispatch = useDispatch();
const { data, loading, error } = useSelector(state => state.user);
useEffect(() => {
const request = dispatch(fetchUser(userId));
return () => request.abort();
}, [userId, dispatch]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return <Profile user={data} />;
}Dispatch returns a promise-like object with abort() and .unwrap(). Reducers should also guard against out-of-order responses when multiple requests for the same slice can overlap, commonly by tracking meta.requestId.
What is RTK Query and when should you use it?
RTK Query is the optional data-fetching and caching layer included in Redux Toolkit. An API slice defines query and mutation endpoints; RTK Query manages request deduplication, loading states, cache lifetimes, subscriptions, and tag-driven invalidation, and its React entry point generates hooks.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: build => ({
getUser: build.query({
query: id => `users/${id}`,
providesTags: (_result, _error, id) => [{ type: 'User', id }]
}),
updateUser: build.mutation({
query: ({ id, ...patch }) => ({
url: `users/${id}`,
method: 'PATCH',
body: patch
}),
invalidatesTags: (_result, _error, { id }) => [{ type: 'User', id }]
})
})
});Use RTK Query when server data should integrate with a Redux application and Redux DevTools. It does not make cache invalidation or optimistic updates automatically correct—the endpoint keys, tag model, authentication refresh, error policy, and SSR integration still require design. In Next.js App Router, Redux's guidance is to fetch server data directly in async Server Components and use RTK Query for client-side fetching.
What is middleware and how does it work in Redux?
Middleware sits between dispatching an action and the reducer receiving it. It can intercept actions, modify them, delay them, or dispatch additional actions. Middleware is how Redux handles side effects like API calls, logging, and analytics.
The middleware signature is store => next => action. Each middleware receives the action, can do something with it, then calls next(action) to pass it along to the next middleware or the reducer.
// Simple logging middleware
const loggerMiddleware = (store) => (next) => (action) => {
console.log('Dispatching:', action.type);
console.log('Current state:', store.getState());
const result = next(action); // Pass to next middleware or reducer
console.log('Next state:', store.getState());
return result;
};
// Analytics middleware
const analyticsMiddleware = (store) => (next) => (action) => {
if (action.type.startsWith('user/')) {
analytics.track(action.type, action.payload);
}
return next(action);
};
// Adding middleware to store
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware, analyticsMiddleware)
});Redux Toolkit's configureStore includes thunk middleware by default, which is why createAsyncThunk works without extra configuration.
What is createListenerMiddleware for?
createListenerMiddleware runs effect logic after matching actions or state transitions. It is a lightweight RTK option for reactive workflows such as analytics, debouncing, “take latest” behavior, waiting for later actions, and cancellation. Listeners can match by action creator, type, matcher, or predicate and receive dispatch, getState, an AbortSignal, take, condition, delay, and fork.
const listener = createListenerMiddleware();
listener.startListening({
actionCreator: searchChanged,
effect: async (action, api) => {
api.cancelActiveListeners();
await api.delay(300);
api.dispatch(searchRequested(action.payload));
}
});
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listener.middleware)
});Use RTK Query for meaningful data-fetching caches, thunks for imperative one-shot logic, listener middleware for action/state-driven workflows, and Saga or observables only when their model clearly earns the added complexity.
What is the difference between Redux Thunk and Redux Saga?
Redux Thunk and Redux Saga are both middleware for handling side effects, but they differ significantly in complexity and capability.
Thunk is simpler—action creators can return functions that receive dispatch and getState. It's easy to learn and sufficient for most async operations like API calls.
Saga uses generator functions and effect descriptions for workflows with explicit concurrency, cancellation, debouncing, and parallel composition. This can make orchestration testable step by step, but it adds a separate runtime and mental model. Listener middleware covers many common reactive workflows with RTK-native primitives.
// Redux Thunk - simple, direct
const fetchUserThunk = (userId) => async (dispatch, getState) => {
dispatch({ type: 'user/fetchPending' });
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
dispatch({ type: 'user/fetchSuccess', payload: data });
} catch (error) {
dispatch({ type: 'user/fetchError', payload: error.message });
}
};
// Redux Saga - generators, more powerful
function* fetchUserSaga(action) {
try {
yield put({ type: 'user/fetchPending' });
const response = yield call(fetch, `/api/users/${action.payload}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = yield call([response, response.json]);
yield put({ type: 'user/fetchSuccess', payload: data });
} catch (error) {
yield put({ type: 'user/fetchError', payload: error.message });
}
}
// Saga advantages: cancellation, race conditions
function* watchFetchUser() {
yield takeLatest('user/fetch', fetchUserSaga); // Auto-cancels previous
}When to choose Saga: when generator-based orchestration and its concurrency operators match an existing complex workflow better than RTK Query, thunks, or listener middleware. Do not add it merely because an operation is asynchronous.
Performance Optimization Questions
Optimizing Redux performance is crucial for large applications.
How do you prevent unnecessary re-renders with useSelector?
useSelector uses strict reference equality (===) by default. If you return a new object or array on every selector run, the component re-renders even if the values inside are identical. Understanding this behavior is key to avoiding performance issues.
There are four main strategies to prevent unnecessary re-renders:
// ❌ Problem: New object every render
function BadExample() {
const userData = useSelector(state => ({
name: state.user.name,
email: state.user.email
})); // New result reference can re-render after every store update.
}
// ✅ Solution 1: Multiple primitive selectors
function Solution1() {
const name = useSelector(state => state.user.name);
const email = useSelector(state => state.user.email);
// Each only triggers re-render when its value changes
}
// ✅ Solution 2: shallowEqual for simple objects
import { shallowEqual } from 'react-redux';
function Solution2() {
const userData = useSelector(
state => ({
name: state.user.name,
email: state.user.email
}),
shallowEqual // Compare values, not reference
);
}
// ✅ Solution 3: Memoized selectors with createSelector
import { createSelector } from '@reduxjs/toolkit';
const selectUserData = createSelector(
[state => state.user.name, state => state.user.email],
(name, email) => ({ name, email }) // Only recalculates when inputs change
);
function Solution3() {
const userData = useSelector(selectUserData);
}
// ✅ Solution 4: useMemo for component-specific logic
function Solution4({ categoryId }) {
const todos = useSelector(state => state.todos);
const filtered = useMemo(
() => todos.filter(t => t.categoryId === categoryId),
[todos, categoryId]
);
}The key insight is that optimization isn't free—createSelector and useMemo have overhead. Measure first, then optimize where needed.
What is createSelector and when should you use it?
createSelector from Reselect creates memoized selectors that recalculate their result function when selected inputs change. It is useful when derived work is expensive or produces a new reference consumed by useSelector; simple property lookup selectors do not need memoization.
Use createSelector when you're computing derived data (filtering, sorting, transforming), when multiple components use the same computation, or when the computation is expensive enough to matter.
import { createSelector } from '@reduxjs/toolkit';
// Input selectors - simple lookups
const selectTodos = state => state.todos;
const selectFilter = state => state.filter;
// Memoized selector - only recalculates when inputs change
const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
console.log('Computing filtered todos'); // Only logs when inputs change
switch (filter) {
case 'completed':
return todos.filter(t => t.completed);
case 'active':
return todos.filter(t => !t.completed);
default:
return todos;
}
}
);
// Parameterized selector
const selectTodosByCategory = createSelector(
[selectTodos, (state, categoryId) => categoryId],
(todos, categoryId) => todos.filter(t => t.categoryId === categoryId)
);
// Usage
const filteredTodos = useSelector(selectFilteredTodos);
const categoryTodos = useSelector(state => selectTodosByCategory(state, 'work'));State Architecture Questions
Designing state structure affects maintainability and performance.
What is state normalization and why is it important?
Normalization means structuring state like a database—entities stored by ID in lookup objects, with arrays of IDs for ordering. This prevents data duplication and makes updates simpler.
Without normalization, the same entity might appear in multiple places. When you need to update it, you must find and update every copy. Normalized state gives each entity a single location, so updates happen once.
// ❌ Nested/duplicated data (hard to update)
const state = {
posts: [
{
id: 1,
title: 'Redux Guide',
author: { id: 1, name: 'John' },
comments: [
{ id: 1, text: 'Great!', author: { id: 2, name: 'Jane' } }
]
}
]
};
// If John changes his name, you'd update it in multiple places
// ✅ Normalized data (single source of truth)
const normalizedState = {
users: {
byId: {
1: { id: 1, name: 'John' },
2: { id: 2, name: 'Jane' }
},
allIds: [1, 2]
},
posts: {
byId: {
1: { id: 1, title: 'Redux Guide', authorId: 1, commentIds: [1] }
},
allIds: [1]
},
comments: {
byId: {
1: { id: 1, text: 'Great!', authorId: 2, postId: 1 }
},
allIds: [1]
}
};
// Change John's name once, it's updated everywhereHow does createEntityAdapter help with normalized state?
Redux Toolkit provides createEntityAdapter to manage normalized state with built-in CRUD operations. It generates a standardized state shape and provides pre-built reducers and selectors.
This eliminates the boilerplate of writing normalization logic yourself while ensuring consistent patterns across your codebase.
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
const usersAdapter = createEntityAdapter();
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState(),
reducers: {
addUser: usersAdapter.addOne,
updateUser: usersAdapter.updateOne,
removeUser: usersAdapter.removeOne,
setAllUsers: usersAdapter.setAll
}
});
// Generated selectors
export const {
selectAll: selectAllUsers,
selectById: selectUserById,
selectIds: selectUserIds
} = usersAdapter.getSelectors(state => state.users);
// State shape: { ids: [1, 2], entities: { 1: {...}, 2: {...} } }When should you NOT use Redux?
Redux adds valuable structure for complex state, but becomes overhead when that complexity doesn't exist. Knowing when to skip Redux is as important as knowing how to use it.
Local or narrowly shared state: If component state, composition, or a small context keeps ownership clear, an external store may add ceremony without benefit. Component count is not a useful threshold.
Server state: If most data comes from APIs, use a purpose-built cache such as RTK Query, TanStack Query, SWR, Apollo Client, or framework data APIs. Choose based on rendering model and ecosystem rather than placing request status and cache policy into hand-written slices by default.
Forms: Libraries like React Hook Form manage form state more elegantly. Putting every keystroke into Redux creates unnecessary actions and hurts performance.
UI state: Modal visibility, dropdown open/closed, hover states—these belong in local component state.
// ❌ Overkill: Redux for server state
const fetchUser = createAsyncThunk('user/fetch', async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
// You'd need to manually implement caching, revalidation, etc.
// One option: a purpose-built client cache
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
refetchOnWindowFocus: true // Auto-refetch when tab regains focus
});
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <Profile user={data} />;
}When Redux IS appropriate: Complex shared state that many components update, when you need middleware for logging or analytics, when time-travel debugging would help, or when a large team benefits from enforced patterns.
Redux Alternatives Questions
Understanding alternatives helps you make informed architectural decisions.
How does Redux compare to Context API?
Redux and Context API serve different purposes. Context is a dependency injection mechanism built into React. Redux is a state management pattern with specific constraints.
When a provider receives a different value by Object.is, React updates consumers that read that context. The cost depends on provider granularity and consumer work; frequently changing data is not categorically forbidden.
React-Redux provides external-store subscriptions through useSelector, which re-renders a component when its selected result changes by the configured equality function. That can be useful for broad, frequently updated state, but Redux's larger value is its complete event/reducer/middleware/tooling model.
| Aspect | Redux | Context API |
|---|---|---|
| Dependency cost | Redux Toolkit + React-Redux | Built into React |
| Subscriptions | Selector result + equality function | Provider value per context |
| DevTools | Excellent | Basic |
| Async Handling | Middleware | Manual |
| Best For | Complex apps, frequent updates | Simple config, infrequent updates |
What is Zustand and how does it compare to Redux?
Zustand is a minimal state management library that offers a simpler API than Redux while maintaining good performance. It uses a hook-based approach without requiring providers.
Zustand favors a compact store API and selector hooks, while Redux Toolkit provides stronger conventions and a larger integrated ecosystem. Verify current bundle output in your actual build rather than relying on quickly outdated minified-size slogans.
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 }))
}));
// No Provider needed—just use the hook
function Counter() {
const count = useStore(state => state.count);
const increment = useStore(state => state.increment);
return <button onClick={increment}>{count}</button>;
}| Aspect | Redux | Zustand |
|---|---|---|
| API surface | Structured toolkit and bindings | Compact store API |
| Provider Required | Yes | No |
| Boilerplate | Medium | Minimal |
| DevTools | Excellent | Good (with middleware) |
| Learning Curve | Medium-Steep | Gentle |
| Typical reason to choose | Event/reducer model, middleware, RTK Query, tooling | Minimal store abstraction and selector hooks |
Libraries can coexist with local state and Context, but every additional state system has synchronization and debugging cost. Prefer clear ownership over a blanket “hybrid is best” rule.
Implementation Questions
Understanding Redux internals helps with debugging and interviews.
How would you implement a simple Redux store from scratch?
Implementing Redux's core from scratch demonstrates understanding of its fundamentals. The store is surprisingly simple—it's just state, a reducer, and a list of listeners.
function createStore(reducer, preloadedState) {
let state = preloadedState;
let listeners = [];
function getState() {
return state;
}
function dispatch(action) {
if (typeof action !== 'object' || action === null) {
throw new Error('Actions must be plain objects');
}
if (typeof action.type === 'undefined') {
throw new Error('Actions must have a type property');
}
state = reducer(state, action);
listeners.forEach(listener => listener());
return action;
}
function subscribe(listener) {
listeners.push(listener);
return function unsubscribe() {
listeners = listeners.filter(l => l !== listener);
};
}
// Initialize state
dispatch({ type: '@@redux/INIT' });
return { getState, dispatch, subscribe };
}How does combineReducers work?
combineReducers creates a root reducer that calls each slice reducer with its portion of state and the action, then merges the results. It also checks if any state actually changed to avoid unnecessary updates.
function combineReducers(reducers) {
return function combination(state = {}, action) {
const nextState = {};
let hasChanged = false;
for (const key in reducers) {
const reducer = reducers[key];
const previousStateForKey = state[key];
const nextStateForKey = reducer(previousStateForKey, action);
nextState[key] = nextStateForKey;
hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
}
hasChanged = hasChanged ||
Object.keys(state).length !== Object.keys(reducers).length;
return hasChanged ? nextState : state;
};
}Understanding this helps you explain Redux's internals and debug unexpected behavior.
Quick Reference
What are the key differences between Classic Redux and Redux Toolkit?
| Concept | Classic Redux | Redux Toolkit |
|---|---|---|
| Store Setup | createStore(reducer) | configureStore({ reducer }) |
| Define Reducer | Manual switch statement | createSlice({ reducers }) |
| Actions | Manual action creators | Auto-generated by createSlice |
| Immutability | Spread operators | Immer (write mutations) |
| Async | Redux Thunk (manual) | createAsyncThunk |
| Entity Management | Manual normalization | createEntityAdapter |
| DevTools | Manual setup | Automatic |
| Middleware | applyMiddleware | Built into configureStore |
For server-data caching, add RTK Query rather than treating createAsyncThunk as a cache. For action/state-driven workflows, consider createListenerMiddleware before introducing a separate Saga or observable runtime.
Official Sources
- Why Redux Toolkit is how to use Redux today
- Redux Toolkit getting started and included APIs
- Writing reducers with Immer
createAsyncThunkcreateListenerMiddleware- RTK Query overview
- RTK Query compared with other tools
useSelectorand React-Redux hooks- Deriving data with selectors
- Redux Toolkit setup with Next.js
Related Articles
- React Hooks Interview Guide - Master useState, useEffect, and custom hooks
- React Advanced Interview Guide - Deep dive into React patterns and performance
- JavaScript Closures Interview Guide - Understanding closures is essential for Redux selectors
- Complete Frontend Developer Interview Guide - Comprehensive preparation guide
Frequently Asked Questions
What are the three core principles of Redux?
Redux describes three principles: global Redux state is represented by one store tree, updates are described by dispatched actions, and pure reducers calculate the next state. Applications can and should still keep local UI state outside Redux. Redux Toolkit reducers may use mutation-like draft syntax because Immer produces immutable results.
What is Redux Toolkit and why should I use it?
Redux Toolkit (RTK) is the official recommended way to write Redux logic. It simplifies store setup with configureStore, reduces boilerplate with createSlice which auto-generates actions, uses Immer for immutable updates with mutation-like syntax, and includes createAsyncThunk for async operations.
When should I use Redux vs React Context?
Context passes a value through a React subtree; consumers update when that provider value changes. Redux adds an external store, action/reducer constraints, middleware, selectors, DevTools, and selective subscriptions. Choose Redux when those capabilities simplify shared state or team workflows, not from a fixed app-size or update-frequency threshold.
What is the difference between Redux Thunk and Redux Saga?
Thunk functions receive dispatch and getState and fit imperative logic. Redux Saga models workflows with generators and effect descriptions, adding cancellation and concurrency operators at a higher conceptual cost. Modern RTK also offers listener middleware for reactive workflows and RTK Query for server-data fetching, so Saga is one option rather than the default next step.
How do you prevent unnecessary re-renders with useSelector?
useSelector re-renders when its selected result differs by strict equality by default. Select the smallest values needed; use multiple selectors, shallowEqual for a small object, or createSelector for derived values that allocate references or are expensive. Profile before optimizing, and keep selectors pure because they may run multiple times.
What is the difference between Redux and Zustand?
Redux Toolkit uses actions, reducers, middleware, selectors, and an explicit Provider, with integrated DevTools and RTK Query. Zustand offers a smaller store API and selector-based hooks, and its default store does not require a Provider. Choose based on state semantics, SSR and dependency-injection needs, middleware/data tooling, team conventions, and measured bundle constraints—not stereotypes about team size.
