React Interview Prep
State Management

Redux Toolkit

The Modern Way

LinkedIn Hook

Everyone says Redux has too much boilerplate.

That was true in 2018. It is not true anymore.

Redux Toolkit (RTK) eliminated the three biggest complaints about Redux: too many files, too much boilerplate, and too much setup for async logic. A single createSlice call replaces action types, action creators, and the reducer — all in one place. createAsyncThunk handles async operations with loading, success, and error states built in. And configureStore sets up the store with devtools and middleware in one line.

But here is what interviewers actually test: they do not ask you to write Redux from scratch anymore. They ask you to explain why Redux exists, when it is overkill, and how RTK solves the problems that made old Redux painful. They want to see if you understand the core concepts — store, slice, reducer, action — or if you just memorized the syntax.

In this lesson, I break down Redux Toolkit from the ground up: why Redux exists as a pattern, what problem a centralized store solves, how createSlice and createAsyncThunk work under the hood, and the exact scenarios where Redux is the right tool versus overkill.

If you have ever set up Redux and thought "this is a lot of code for a counter" — RTK is the answer. But you still need to know when the answer is "do not use Redux at all."

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #StateManagement #Redux #ReduxToolkit #RTK #CodingInterview #100DaysOfCode


Redux Toolkit thumbnail


What You'll Learn

  • Why Redux exists as a pattern and what problem a centralized store solves
  • The core building blocks: store, slice, reducer, and action
  • How createSlice eliminates boilerplate while keeping Redux predictable
  • How useSelector and useDispatch connect React components to the store
  • How createAsyncThunk handles async operations with built-in loading states
  • What RTK Query is and when it replaces createAsyncThunk
  • When Redux is overkill and you should use a simpler solution

The Concept — Why Redux Exists

Analogy: The Central Bank

Imagine a country where every city manages its own currency independently. City A prints its own money, City B does the same, and when a citizen moves from A to B, nothing syncs. Prices are inconsistent, transfers are chaotic, and nobody has a clear picture of the total money supply.

That is what happens in a React app when every component manages its own state independently. The shopping cart state lives in one component, the user auth state lives in another, the notification count lives somewhere else. When these need to talk to each other, you end up passing props through 10 layers or firing events that nobody can trace.

Redux is the central bank. All the state lives in one place — the store. Every change goes through a formal process: a component sends a request (dispatches an action), the central bank follows its rulebook (reducer) to decide exactly how the state changes, and then every branch office (component) that subscribed to relevant data gets the update automatically.

The key insight: nobody modifies state directly. Every change is an action — a plain object that says what happened. The reducer is a pure function that takes the current state and the action, and returns the new state. This makes every state change predictable, traceable, and replayable.

Redux Toolkit (RTK) is the central bank's modern upgrade. The old system required filling out three separate forms for every transaction (action type constants, action creator functions, reducer switch cases). RTK gives you a single form — createSlice — that generates all three automatically.


The Building Blocks

Before touching code, understand the five pieces and how they connect:

  1. Store — The single source of truth. One JavaScript object that holds all application state.
  2. Slice — A section of the store responsible for one domain (auth, cart, notifications). Each slice has its own reducer and actions.
  3. Reducer — A pure function inside each slice: (state, action) => newState. Decides how state changes in response to actions.
  4. Action — A plain object { type: 'cart/addItem', payload: item } that describes what happened. You never modify state directly — you dispatch actions.
  5. Selectors — Functions that extract specific data from the store. Components use useSelector to subscribe to only the slice they need.

The data flow is always one direction: Component dispatches action -> Reducer processes action -> Store updates -> Subscribed components re-render.

Redux Toolkit visual 1


Code Examples

Code Example 1: Setting Up a Store with createSlice

This example shows the complete setup for a counter — the simplest possible Redux Toolkit app.

// store/counterSlice.js
import { createSlice } from "@reduxjs/toolkit";

// createSlice generates action creators and action types automatically
// You write the reducer logic, RTK handles the rest
const counterSlice = createSlice({
  name: "counter", // Used as prefix for action types: "counter/increment"
  initialState: {
    value: 0,
  },
  reducers: {
    // Each key becomes an action creator AND a reducer case
    increment(state) {
      // RTK uses Immer under the hood — you CAN mutate state directly
      // Immer converts this to an immutable update behind the scenes
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action) {
      // action.payload contains the value passed to the action creator
      state.value += action.payload;
    },
    reset(state) {
      state.value = 0;
    },
  },
});

// RTK auto-generates action creators from the reducers object
// counterSlice.actions.increment() produces { type: "counter/increment" }
// counterSlice.actions.incrementByAmount(5) produces { type: "counter/incrementByAmount", payload: 5 }
export const { increment, decrement, incrementByAmount, reset } =
  counterSlice.actions;

// Export the reducer to plug into the store
export default counterSlice.reducer;


// store/index.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";

// configureStore sets up Redux DevTools and default middleware automatically
const store = configureStore({
  reducer: {
    counter: counterReducer, // state.counter will hold this slice's state
  },
});

export default store;


// main.jsx — Wrap the app with the Redux Provider
import { Provider } from "react-redux";
import store from "./store";
import App from "./App";

// Provider makes the store available to all components via context
// Every component inside Provider can use useSelector and useDispatch
<Provider store={store}>
  <App />
</Provider>


// App.jsx — Use the store in components
import { useSelector, useDispatch } from "react-redux";
import { increment, decrement, incrementByAmount, reset } from "./store/counterSlice";

function Counter() {
  // useSelector subscribes to a slice of state
  // This component ONLY re-renders when state.counter.value changes
  const count = useSelector((state) => state.counter.value);

  // useDispatch returns the store's dispatch function
  const dispatch = useDispatch();

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(incrementByAmount(10))}>+10</button>
      <button onClick={() => dispatch(reset())}>Reset</button>
    </div>
  );
}

// Initial render:
// Output: Count: 0
// Click +1 three times:
// Output: Count: 3
// Click +10:
// Output: Count: 13
// Click Reset:
// Output: Count: 0

Key point: createSlice eliminated three things you used to write manually — action type string constants, action creator functions, and the reducer switch statement. One function does all three.


Code Example 2: Real-World Shopping Cart Slice

This example shows a more realistic slice with computed logic inside reducers.

// store/cartSlice.js
import { createSlice } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: {
    items: [],       // Array of { id, name, price, quantity }
    totalAmount: 0,  // Running total
  },
  reducers: {
    addItem(state, action) {
      const newItem = action.payload; // { id, name, price }
      const existingItem = state.items.find((item) => item.id === newItem.id);

      if (existingItem) {
        // Item already in cart — increase quantity
        // Immer lets us mutate directly — no spread operators needed
        existingItem.quantity += 1;
      } else {
        // New item — add with quantity 1
        state.items.push({ ...newItem, quantity: 1 });
      }

      // Recalculate total
      state.totalAmount = state.items.reduce(
        (total, item) => total + item.price * item.quantity,
        0
      );
    },

    removeItem(state, action) {
      const id = action.payload;
      const existingItem = state.items.find((item) => item.id === id);

      if (existingItem.quantity === 1) {
        // Last one — remove entirely from array
        state.items = state.items.filter((item) => item.id !== id);
      } else {
        // More than one — decrease quantity
        existingItem.quantity -= 1;
      }

      state.totalAmount = state.items.reduce(
        (total, item) => total + item.price * item.quantity,
        0
      );
    },

    clearCart(state) {
      state.items = [];
      state.totalAmount = 0;
    },
  },
});

export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;


// Components using the cart slice
import { useSelector, useDispatch } from "react-redux";
import { addItem, removeItem, clearCart } from "./store/cartSlice";

function CartBadge() {
  // Select only the data you need — this component re-renders
  // ONLY when the total item count changes, not on other store changes
  const itemCount = useSelector((state) =>
    state.cart.items.reduce((count, item) => count + item.quantity, 0)
  );

  return <span>Cart ({itemCount})</span>;
}

function CartTotal() {
  const totalAmount = useSelector((state) => state.cart.totalAmount);
  return <p>Total: ${totalAmount.toFixed(2)}</p>;
}

function ProductCard({ product }) {
  const dispatch = useDispatch();

  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => dispatch(addItem(product))}>Add to Cart</button>
    </div>
  );
}

// Adding a product { id: 1, name: "Laptop", price: 999 } twice:
// Output — CartBadge: Cart (2)
// Output — CartTotal: Total: $1998.00
// Removing one:
// Output — CartBadge: Cart (1)
// Output — CartTotal: Total: $999.00

Key point: Immer (built into RTK) lets you write state.items.push(...) and existingItem.quantity += 1 — code that looks like mutation but produces immutable updates. This is the single biggest ergonomic improvement over classic Redux.


Code Example 3: Async Operations with createAsyncThunk

Real apps fetch data from APIs. createAsyncThunk handles the async lifecycle — pending, fulfilled, rejected — so you do not have to dispatch three separate actions manually.

// store/postsSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

// createAsyncThunk generates three action types automatically:
// "posts/fetchPosts/pending"   — dispatched when the request starts
// "posts/fetchPosts/fulfilled" — dispatched when the request succeeds
// "posts/fetchPosts/rejected"  — dispatched when the request fails
export const fetchPosts = createAsyncThunk(
  "posts/fetchPosts", // Action type prefix
  async (_, thunkAPI) => {
    // The first argument is whatever you pass when dispatching
    // thunkAPI gives access to dispatch, getState, rejectWithValue, etc.
    const response = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5");

    if (!response.ok) {
      // rejectWithValue sends a custom error payload to the rejected case
      return thunkAPI.rejectWithValue("Failed to fetch posts");
    }

    const data = await response.json();
    return data; // This becomes action.payload in the fulfilled case
  }
);

const postsSlice = createSlice({
  name: "posts",
  initialState: {
    items: [],
    status: "idle", // "idle" | "loading" | "succeeded" | "failed"
    error: null,
  },
  reducers: {
    // Regular synchronous reducers still go here
    clearPosts(state) {
      state.items = [];
      state.status = "idle";
    },
  },
  // extraReducers handles actions from createAsyncThunk
  // (and any other actions not defined in this slice's reducers)
  extraReducers(builder) {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.status = "loading";
        state.error = null;
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.items = action.payload; // The data returned from the thunk
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.payload || action.error.message;
      });
  },
});

export const { clearPosts } = postsSlice.actions;
export default postsSlice.reducer;


// Component that uses the async thunk
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { fetchPosts } from "./store/postsSlice";

function PostList() {
  const dispatch = useDispatch();
  const { items, status, error } = useSelector((state) => state.posts);

  useEffect(() => {
    // Only fetch if we have not already fetched
    if (status === "idle") {
      dispatch(fetchPosts());
    }
  }, [status, dispatch]);

  // Render based on the current status
  if (status === "loading") return <p>Loading posts...</p>;
  if (status === "failed") return <p>Error: {error}</p>;

  return (
    <ul>
      {items.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

// On mount (status is "idle"):
// Output: Loading posts...
// After fetch succeeds:
// Output: List of 5 post titles
// If fetch fails:
// Output: Error: Failed to fetch posts

Key point: createAsyncThunk replaces the manual pattern of dispatching FETCH_START, FETCH_SUCCESS, and FETCH_ERROR actions. The three lifecycle states (pending/fulfilled/rejected) are generated automatically, and you handle them in extraReducers.

Redux Toolkit visual 2


RTK Query — A Brief Mention

RTK Query is a data fetching and caching tool built into Redux Toolkit. It replaces createAsyncThunk for most API interactions.

Instead of writing a slice, a thunk, loading states, and caching logic manually, RTK Query does all of it from a single API definition:

// RTK Query replaces manual thunks with a declarative API definition
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

const postsApi = createApi({
  reducerPath: "postsApi",
  baseQuery: fetchBaseQuery({ baseUrl: "https://jsonplaceholder.typicode.com" }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => "/posts?_limit=5",
    }),
  }),
});

// Auto-generated hook — no useEffect, no loading state management
export const { useGetPostsQuery } = postsApi;

// In a component — one line replaces the entire PostList useEffect pattern
function PostList() {
  const { data: posts, isLoading, error } = useGetPostsQuery();

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error loading posts</p>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

When to use RTK Query vs createAsyncThunk: If you are fetching data from a REST or GraphQL API, RTK Query is almost always the better choice — it handles caching, deduplication, refetching, and cache invalidation automatically. Use createAsyncThunk for non-API async operations (e.g., reading from local storage, complex multi-step workflows). Interviewers rarely ask deep RTK Query questions, but mentioning it shows you know the modern Redux ecosystem.


When Redux Is Overkill

Redux is a powerful tool, but it adds complexity. Here is a practical decision framework:

ScenarioUse Redux?Better Alternative
Theme toggle (light/dark)NoContext or CSS variables
Auth state (current user)MaybeContext if reads are infrequent
Shopping cart (5 components)NoContext + useReducer
Shopping cart (20+ components, complex logic)YesRedux Toolkit
Server state (API data, caching)MaybeReact Query or RTK Query
Form state (input values)NoLocal state or React Hook Form
Real-time dashboard (frequent updates)YesRedux or Zustand
Small app with 2-3 pagesNoContext or Zustand
Large team, many feature modulesYesRedux Toolkit (standardized patterns)

The rule of thumb: If you can describe your state management needs in one sentence, Redux is probably overkill. If you need a paragraph, it might be the right tool.

Redux shines when you have:

  • Multiple components across different parts of the tree reading and writing the same state
  • Complex update logic where one action triggers changes in multiple slices
  • A large team that benefits from Redux's opinionated structure and devtools
  • Need for middleware — logging, analytics, undo/redo, optimistic updates

Common Mistakes

Mistake 1: Mutating state outside of createSlice reducers

// BAD: Immer only works inside createSlice reducers
// Outside of them, this is real mutation and Redux will not detect the change
function SomeComponent() {
  const cart = useSelector((state) => state.cart);

  function handleClick() {
    // This mutates the store directly — no action, no reducer
    // Redux will NOT re-render components because it did not detect a change
    cart.items.push({ id: 1, name: "Laptop" });
  }

  return <button onClick={handleClick}>Add</button>;
}

// GOOD: Always dispatch an action — let the reducer handle state changes
function SomeComponent() {
  const dispatch = useDispatch();

  function handleClick() {
    // Dispatch an action — the reducer creates the new state via Immer
    dispatch(addItem({ id: 1, name: "Laptop", price: 999 }));
  }

  return <button onClick={handleClick}>Add</button>;
}

Mistake 2: Selecting too much state in useSelector

// BAD: Selecting the entire state object
// This component re-renders on ANY state change in the entire store
function CartBadge() {
  const state = useSelector((state) => state);
  return <span>Cart ({state.cart.items.length})</span>;
}

// ALSO BAD: Creating a new object in the selector
// A new object is created every render, so React thinks the value changed
function CartBadge() {
  const cart = useSelector((state) => ({
    count: state.cart.items.length,
    total: state.cart.totalAmount,
  }));
  // This re-renders on EVERY store change because {} !== {}
  return <span>Cart ({cart.count})</span>;
}

// GOOD: Select the smallest primitive value you need
function CartBadge() {
  const itemCount = useSelector((state) => state.cart.items.length);
  // Only re-renders when the actual item count changes
  return <span>Cart ({itemCount})</span>;
}

// ALSO GOOD: Use shallowEqual for multiple values
import { shallowEqual } from "react-redux";

function CartSummary() {
  const { count, total } = useSelector(
    (state) => ({
      count: state.cart.items.length,
      total: state.cart.totalAmount,
    }),
    shallowEqual // Compares each property instead of reference equality
  );
  return <p>{count} items — ${total.toFixed(2)}</p>;
}

Mistake 3: Putting everything in Redux

// BAD: Storing form input values in Redux
// Every keystroke dispatches an action and updates the store
function SearchBar() {
  const query = useSelector((state) => state.ui.searchQuery);
  const dispatch = useDispatch();

  return (
    <input
      value={query}
      onChange={(e) => dispatch(setSearchQuery(e.target.value))}
    />
  );
  // This works but is massive overkill — local state is simpler and faster
}

// GOOD: Use local state for UI-only values
// Only dispatch to Redux when you need other components to know
function SearchBar() {
  const [query, setQuery] = useState("");
  const dispatch = useDispatch();

  function handleSubmit(e) {
    e.preventDefault();
    // Only put the final search term in Redux when the user submits
    dispatch(executeSearch(query));
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button type="submit">Search</button>
    </form>
  );
}

Interview Questions

Q: Why does Redux exist? What problem does it solve?

Redux solves the problem of shared state across many components that are far apart in the component tree. Without Redux, you either prop-drill through many layers or use Context which has no selectors and re-renders all consumers. Redux provides a single centralized store with predictable state updates through actions and reducers, fine-grained subscriptions through selectors, middleware for side effects, and devtools for debugging. It makes state changes traceable — every change is an action you can log, replay, and time-travel through.

Q: What is createSlice and what does it replace from classic Redux?

createSlice is a Redux Toolkit function that generates a reducer, action creators, and action type strings from a single configuration object. In classic Redux, you had to manually write action type constants (const INCREMENT = "INCREMENT"), action creator functions (function increment() { return { type: INCREMENT } }), and a reducer with a switch statement. createSlice replaces all three. It also uses Immer internally, so you can write "mutating" code in reducers that is automatically converted to immutable updates.

Q: How does Immer work inside Redux Toolkit reducers?

Immer creates a draft proxy of your state object. When you write state.value += 1 inside a createSlice reducer, you are actually modifying the proxy, not the real state. After your reducer runs, Immer compares the draft to the original, computes the differences, and produces a new immutable state object with only the changed parts updated. This gives you the developer experience of direct mutation with the safety of immutable updates. Immer only works inside createSlice reducers — mutating state anywhere else is a real mutation and a bug.

Q: What is the difference between useSelector and useContext for reading state?

useSelector subscribes to a specific slice of the Redux store and only triggers a re-render when that selected value changes (using strict equality === by default). useContext subscribes to the entire context value and re-renders the component whenever any part of the context changes — there is no built-in way to select a subset. This means Redux gives you fine-grained re-render control out of the box, while Context requires manual splitting to achieve similar behavior.

Q: When would you choose Redux Toolkit over Context + useReducer or Zustand?

Choose Redux Toolkit when you have a large application with many state domains, a large team that benefits from standardized patterns, need for middleware (logging, analytics, undo/redo), or need for powerful devtools with time-travel debugging. Choose Context + useReducer for small apps with 1-3 state domains and infrequent updates. Choose Zustand for medium apps where you want Redux-like patterns with less boilerplate and no Provider wrapper. Redux Toolkit is the strongest choice when the application is expected to grow significantly — its opinionated structure prevents the codebase from becoming inconsistent as more developers contribute.


Quick Reference — Cheat Sheet

REDUX TOOLKIT (RTK)
====================

Core setup:
  configureStore({ reducer: { sliceName: sliceReducer } })
  <Provider store={store}>  — wrap app in provider

createSlice:
  createSlice({ name, initialState, reducers, extraReducers })
  - name:         prefix for action types ("cart/addItem")
  - initialState: starting state for this slice
  - reducers:     sync reducer functions (auto-generates action creators)
  - extraReducers: handles thunk actions and actions from other slices

Reading state:
  useSelector(state => state.cart.items)   — subscribe to a slice
  useSelector(fn, shallowEqual)            — compare object properties

Dispatching actions:
  const dispatch = useDispatch()
  dispatch(addItem({ id: 1, name: "Laptop" }))

createAsyncThunk:
  createAsyncThunk("posts/fetch", async (arg, thunkAPI) => { ... })
  Generates: pending / fulfilled / rejected action types
  Handle in extraReducers with builder.addCase()

RTK Query (brief):
  createApi({ baseQuery, endpoints })
  Auto-generates hooks: useGetPostsQuery()
  Handles: caching, deduplication, refetching, invalidation

Immer rules:
  - MUTATE inside createSlice reducers: state.value += 1
  - NEVER mutate outside reducers (in components, thunks)
  - Works by proxy — produces immutable updates from mutation syntax

+------------------------+-------------------+-------------------+
| Feature                | Classic Redux     | Redux Toolkit     |
+------------------------+-------------------+-------------------+
| Action types           | Manual strings    | Auto-generated    |
| Action creators        | Manual functions  | Auto-generated    |
| Reducer syntax         | Switch statement  | Object methods    |
| Immutable updates      | Spread operators  | Immer (mutate)    |
| Store setup            | 20+ lines         | 5 lines           |
| Async logic            | redux-thunk manual| createAsyncThunk  |
| API caching            | DIY               | RTK Query         |
| DevTools               | Manual setup      | Automatic         |
+------------------------+-------------------+-------------------+

When to use Redux:
  - Many components share state across distant parts of the tree
  - Complex update logic (one action affects multiple slices)
  - Large team needing standardized patterns
  - Need middleware, devtools, or time-travel debugging

When NOT to use Redux:
  - Small app with 1-3 state domains (use Context)
  - Form input values (use local state)
  - Server cache only (use React Query / TanStack Query)
  - Theme / locale toggle (use Context)

Previous: Lesson 6.2 — Context API + useReducer Pattern -> Next: Lesson 6.4 — Zustand, Jotai & Modern Alternatives ->


This is Lesson 6.3 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page