50+ Redux Interview Questions 2025: Redux Toolkit, Middleware & State Management

·23 min read
reduxreactstate-managementredux-toolkitfrontendjavascriptinterview-preparation

According to the State of JavaScript 2024 survey, Redux remains the most widely-used state management library in production React applications, with Redux Toolkit adoption growing 40% year-over-year. Yet in interviews, candidates often stumble not on Redux syntax, but on understanding when Redux is the right tool—and when it's overkill.

This guide covers what developers need to know for 2025 interviews: the core concepts that haven't changed, the modern Redux Toolkit patterns that have replaced verbose boilerplate, and the critical architectural decisions around state management that separate thoughtful engineers from those who just reach for Redux by default.

Table of Contents

  1. Redux Core Concepts Questions
  2. Redux Data Flow Questions
  3. Redux Toolkit Questions
  4. Async and Middleware Questions
  5. Performance Optimization Questions
  6. State Architecture Questions
  7. Redux Alternatives Questions
  8. Implementation Questions
  9. 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 application state in a single store and enforces unidirectional data flow where state can only be changed by dispatching actions that describe what happened. Reducers—pure functions—take the current state and an action, returning the new state without mutations.

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—your entire application state lives in one JavaScript object tree within one store. This centralization makes state easier to debug, persist, and inspect at any point in time. You always know exactly where to look for any piece of state.

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 }
};
// All app state lives in this ONE object 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?

If you mutate state directly in a reducer, React-Redux won't detect the change because its reference equality check passes—the object reference is the same even though the contents changed. As a result, components won't re-render and your UI falls out of sync with your actual state.

This is one of the most common Redux bugs. The state is updated internally, but because React-Redux compares object references to decide whether to re-render, it sees the same reference and assumes nothing changed.

// ❌ 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 replaces current state with this new state and notifies all subscribed listeners. In React, React-Redux has subscribed to the store. Components using useSelector re-run their selectors. If selected values changed, those components re-render.

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 returns new state → store updates
  };
 
  // 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.

When you return a value, Immer ignores any mutations you made to the draft and uses your returned value as the new state. This is commonly used for resetting state to initial values or completely replacing a slice of state.

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 is RTK's built-in solution for async logic. It generates a thunk action creator that dispatches lifecycle actions automatically—pending when the async call starts, fulfilled when it succeeds, rejected when it fails.

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, thunkAPI) => {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error('Failed to fetch user');
    }
    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;
        state.error = action.error.message;
      });
  }
});
 
// Usage in component
function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const { data, loading, error } = useSelector(state => state.user);
 
  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [userId, dispatch]);
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <Profile user={data} />;
}

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 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 for complex async flows. It offers better handling of race conditions, cancellation, debouncing, and parallel execution. Sagas are also more testable since they yield descriptions of effects rather than executing them directly.

// 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 data = yield call(fetch, `/api/users/${action.payload}`);
    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 you need complex async flows—cancellation, debouncing, race conditions, parallel execution, or when testing async logic is a priority.


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
  })); // Re-renders on 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 only recalculate when their inputs change. This is essential for derived data—computations based on state that would otherwise run on every store update.

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 everywhere

How 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.

Simple applications: If you have fewer than 10 components and minimal shared state, useState or useReducer with occasional prop drilling serves you better.

Server state: If most of your state comes from APIs—user profiles, product lists, search results—use React Query, SWR, or Apollo Client instead. These handle caching, revalidation, and background refetching automatically.

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.
 
// ✅ Better: React Query handles it all
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.

Context works well for values that change infrequently—themes, locale, feature flags, authentication status. But every context value change triggers re-renders of all consuming components, making it unsuitable for frequently-updating state.

Redux provides optimized subscriptions through useSelector, which only re-renders components when their selected values change. This makes Redux better for complex state with many updates.

AspectReduxContext API
Bundle Size~10kb0 (built-in)
Re-render OptimizationExcellent with selectorsPoor (all consumers re-render)
DevToolsExcellentBasic
Async HandlingMiddlewareManual
Best ForComplex apps, frequent updatesSimple 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 suits modern applications that prioritize developer experience and don't need Redux's strict patterns. It's ~1kb compared to Redux's ~10kb and requires less boilerplate.

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>;
}
AspectReduxZustand
Bundle Size~10kb~1kb
Provider RequiredYesNo
BoilerplateMediumMinimal
DevToolsExcellentGood (with middleware)
Learning CurveMedium-SteepGentle
Best ForLarge teams, strict patternsModern apps, DX priority

The hybrid approach often works best: Context for stable configuration, Redux or Zustand for dynamic application state.


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;
    }
 
    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?

ConceptClassic ReduxRedux Toolkit
Store SetupcreateStore(reducer)configureStore({ reducer })
Define ReducerManual switch statementcreateSlice({ reducers })
ActionsManual action creatorsAuto-generated by createSlice
ImmutabilitySpread operatorsImmer (write mutations)
AsyncRedux Thunk (manual)createAsyncThunk
Entity ManagementManual normalizationcreateEntityAdapter
DevToolsManual setupAutomatic
MiddlewareapplyMiddlewareBuilt into configureStore

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides