Here is a Redux action creator from 2017. Take a look - it is almost the smallest useful one you can write.

TypeScript actions/todos.ts
export const ADD_TODO = "ADD_TODO";
export const TOGGLE_TODO = "TOGGLE_TODO";

export const addTodo = (text: string) => ({
  type: ADD_TODO,
  payload: { id: nextId(), text, done: false },
});

export const toggleTodo = (id: string) => ({
  type: TOGGLE_TODO,
  payload: { id },
});

Then a reducer in a second file:

TypeScript reducers/todos.ts
const initialState = { items: [] as Todo[] };

export default function todos(state = initialState, action: AnyAction) {
  switch (action.type) {
    case ADD_TODO:
      return { ...state, items: [...state.items, action.payload] };
    case TOGGLE_TODO:
      return {
        ...state,
        items: state.items.map((t) =>
          t.id === action.payload.id ? { ...t, done: !t.done } : t,
        ),
      };
    default:
      return state;
  }
}

Then a store setup file that wires Redux DevTools by hand, adds redux-thunk, and probably imports a combineReducers you forgot to update when you added a slice. Three files, two action constants, one switch statement, one spread-the-world reducer, and a global helper to remember that no, you really cannot just push into that array. For two operations on one entity. Crazy, right?

This is the kind of code Redux earned its "too much ceremony" reputation on - and the reason a lot of teams quietly migrated to MobX, Zustand, or just useContext. Redux Toolkit (RTK) is the reaction. It is the same Redux, the same store, the same DevTools timeline, the same single-source-of-truth contract - but the surface you write against is unrecognizable. One slice file replaces the three files above. The reducer looks mutable but isn't. Async fetches generate their own action types. And server-state caching is built in, so you stop hand-rolling loading, error, data triplets for every endpoint.

Let's break down what actually changed and why each piece exists.

The Reset Button: From "Starter Kit" to The Official Way

Redux Toolkit started life in 2018 as "Redux Starter Kit", a small library Mark Erikson and the Redux team published to bundle the patterns the community had converged on. It was renamed to Redux Toolkit and shipped 1.0 in October 2019. The pitch in the docs is brutally direct - it lists "three common concerns about Redux":

"Configuring a Redux store is too complicated."

"I have to add a lot of packages to get Redux to do anything useful."

"Redux requires too much boilerplate code."

RTK is the answer to all three, written by the people who maintain Redux itself. It is now described on the GitHub repo as "the official, opinionated, batteries-included toolset for efficient Redux development," and the Redux core tutorials recommend it as the default way to learn Redux. If you are writing Redux in 2026 without RTK, you are writing the legacy dialect on purpose.

RTK Query - the data-fetching/caching layer - was added later, in RTK 1.6 (June 2021). RTK 2.0 shipped on December 4, 2023, dropped a pile of legacy syntax, modernized the build (ESM + CJS, no UMD), and introduced combineSlices for lazy reducer injection. We'll touch on 2.0's breaking changes near the end.

configureStore: The Store Setup You'd Have Written Anyway

Old Redux store setup is the kind of code nobody reads twice but everyone copies forever:

TypeScript store.ts (classic Redux)
import { createStore, applyMiddleware, combineReducers, compose } from "redux";
import thunk from "redux-thunk";
import todos from "./reducers/todos";
import user from "./reducers/user";

const rootReducer = combineReducers({ todos, user });

const composeEnhancers =
  (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

const store = createStore(
  rootReducer,
  composeEnhancers(applyMiddleware(thunk)),
);

Five imports, a custom enhancer dance for DevTools, and a middleware list you'll forget to update. The RTK version:

TypeScript store.ts (RTK)
import { configureStore } from "@reduxjs/toolkit";
import todosReducer from "./features/todos/todosSlice";
import userReducer from "./features/user/userSlice";

export const store = configureStore({
  reducer: { todos: todosReducer, user: userReducer },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

That's the whole file. configureStore does four things automatically that you used to do by hand:

  1. Combines reducers if you pass an object (no need to call combineReducers yourself).
  2. Wires the Redux DevTools Extension - no manual composeEnhancers dance.
  3. Adds redux-thunk by default, so dispatch(someThunk()) works out of the box.
  4. Installs two development-only middleware via getDefaultMiddleware(): an immutability check that throws if you mutate state outside of a reducer, and a serializability check that throws if you put a non-serializable value (a Date, a class instance, a Promise, FormData) into the store or dispatch one in an action.

That last one is the one people stub their toe on. The first time you dispatch({ type: "set/date", payload: new Date() }), RTK will warn loudly in the console. The fix is almost always to convert to a serializable form (ISO string, number) at the boundary - that's the contract Redux has always asked for, but it never used to be enforced. Now it is, and it catches a category of "but it works in dev" bugs before they reach the store.

If you have a legitimate reason to store something non-serializable (the redux-persist rehydrate action, RTK Query's internal cache entries, file uploads in flight), you opt out per-action-type or per-path:

TypeScript store.ts
configureStore({
  reducer: rootReducer,
  middleware: (getDefault) =>
    getDefault({
      serializableCheck: {
        ignoredActions: ["persist/REHYDRATE"],
        ignoredPaths: ["uploads.formData"],
      },
    }),
});

Opt-out is explicit and scoped. That's the pattern RTK uses for almost every safety net: on by default, off where you actually need it.

createSlice: The Centerpiece

Slices are the move that makes Redux feel like a different library. One file, one slice, all related logic together.

TypeScript features/todos/todosSlice.ts
import { createSlice, PayloadAction, nanoid } from "@reduxjs/toolkit";

interface Todo {
  id: string;
  text: string;
  done: boolean;
}

interface TodosState {
  items: Todo[];
  filter: "all" | "active" | "done";
}

const initialState: TodosState = { items: [], filter: "all" };

const todosSlice = createSlice({
  name: "todos",
  initialState,
  reducers: {
    add: {
      reducer(state, action: PayloadAction<Todo>) {
        state.items.push(action.payload); // looks like mutation, isn't
      },
      prepare(text: string) {
        return { payload: { id: nanoid(), text, done: false } };
      },
    },
    toggle(state, action: PayloadAction<string>) {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) todo.done = !todo.done;
    },
    setFilter(state, action: PayloadAction<TodosState["filter"]>) {
      state.filter = action.payload;
    },
  },
});

export const { add, toggle, setFilter } = todosSlice.actions;
export default todosSlice.reducer;

Three reducers, three action creators auto-generated with names like "todos/add", "todos/toggle", "todos/setFilter". The action types you no longer write as constants are still there - open Redux DevTools and you'll see them - they're just derived from name + reducerName so you can't mistype one in a switch case.

A few things to notice that are easy to gloss over:

The prepare callback lets you customize the payload before it lands in the reducer (here, generating an id with nanoid). Splitting prepare from reducer keeps the reducer itself a pure function of state + payload - non-deterministic bits live in prepare.

extraReducers is the second key on createSlice. It listens to actions defined outside this slice - typically thunks (next section) or actions from other slices. In RTK 2.0 it's a builder callback only:

TypeScript
extraReducers: (builder) => {
  builder
    .addCase(fetchTodos.fulfilled, (state, action) => {
      state.items = action.payload;
    })
    .addCase(fetchTodos.rejected, (state, action) => {
      state.error = action.error.message ?? "Unknown error";
    })
    .addMatcher(isPending, (state) => {
      state.loading = true;
    });
},

The old object syntax (extraReducers: { [fetchTodos.fulfilled]: ... }) was removed in RTK 2.0. If you're migrating an older codebase, this is the one breaking change you will hit on every slice that had async logic. The migration is mechanical - wrap your map of action types into a builder.addCase chain - but it is not optional.

createSlice returns more than just reducer and actions. Since RTK 2.0 it also returns selectors and getSelectors, so you can define memoised selectors next to the slice instead of in a separate file:

TypeScript
const todosSlice = createSlice({
  name: "todos",
  initialState,
  reducers: { /* ... */ },
  selectors: {
    selectVisible: (state) =>
      state.filter === "all"
        ? state.items
        : state.items.filter((t) => (state.filter === "done") === t.done),
  },
});

export const { selectVisible } = todosSlice.selectors;

These selectors receive the slice's own state automatically - they're scoped, so when you use the slice under a different key, the selectors still work.

Immer: The Reason You Can "Mutate"

The line in the slice that does state.items.push(...) is the part that makes long-time Redux users twitch. Redux's first commandment is thou shalt not mutate state. So how does this work?

RTK wraps every createSlice and createReducer case reducer in Immer's produce. Immer hands you a special object called a draft - a Proxy over the current state. When you read from the draft, it returns the original value. When you write to it, Immer records the change but doesn't actually mutate the original. After your reducer returns, Immer walks the recorded changes and produces a brand-new immutable object that shares structure with the old one: branches you didn't touch are reused by reference, branches you did touch are copies.

The end result is the same immutable update you'd get from spreading by hand. It just reads like 2003 JavaScript.

There are three rules to remember, and they bite people roughly once each:

1. Mutate the draft, or return a new value - never both. This is undefined behavior:

TypeScript
toggle(state, action) {
  state.items[0].done = true;       // mutated
  return { ...state, items: [] };   // also returned - DON'T
},

Pick one. If you're replacing the whole slice, just return; if you're tweaking, mutate.

2. Map and Set need an opt-in. Immer doesn't draft Map or Set by default - using one in state will silently fail to update. Call enableMapSet() once at app startup if you need them. Most teams don't - plain objects and arrays compose better with selectors anyway - but it's the kind of thing that takes an hour to diagnose the first time.

3. Don't mutate references that escape the draft. If you store a fetched array in state and then somewhere else in your app you do state.items[0].name = "x" on the reference you got from useSelector, Immer is not in the picture - you've just mutated a real piece of frozen state. RTK's immutableCheck middleware will throw in development, which is exactly why it exists. In production you'd get a stale render and a hard-to-debug DevTools timeline. Treat anything that comes out of the store as read-only outside of reducers.

When you need to inspect a draft (debugging a reducer in DevTools or via console.log), wrap it in current():

TypeScript
import { current } from "@reduxjs/toolkit";

toggle(state, action) {
  console.log(current(state.items));  // plain snapshot, readable
  const todo = state.items.find((t) => t.id === action.payload);
  if (todo) todo.done = !todo.done;
},

Logging the draft directly prints a Proxy object that's useless in the console.

createAsyncThunk: Async Without The Boilerplate

A classic Redux async fetch is three action types, three reducers, one action creator that returns a function, and a try/catch. Every endpoint, every time. createAsyncThunk collapses that into one function.

TypeScript features/todos/todosSlice.ts
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

export const fetchTodos = createAsyncThunk(
  "todos/fetch",
  async (userId: string, thunkAPI) => {
    const res = await fetch(`/api/users/${userId}/todos`);
    if (!res.ok) {
      return thunkAPI.rejectWithValue({ status: res.status });
    }
    return (await res.json()) as Todo[];
  },
);

That single call generates three action types: "todos/fetch/pending", "todos/fetch/fulfilled", "todos/fetch/rejected". Your slice handles whichever ones it cares about in extraReducers:

TypeScript
const todosSlice = createSlice({
  name: "todos",
  initialState: { items: [] as Todo[], loading: false, error: null as string | null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchTodos.fulfilled, (state, action) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchTodos.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message ?? "Failed to fetch";
      });
  },
});

A few features that are worth knowing about before you reach for them six months in:

rejectWithValue - by default a thrown error lands on action.error (a serialised version of the Error). If you want a structured rejection payload (status code, validation errors), use rejectWithValue and it lands on action.payload with action.error.message === "Rejected". Use it whenever the caller cares about why it failed in a structured way.

condition - a guard that runs before the thunk dispatches anything. Return false and the thunk short-circuits entirely; no pending, no fulfilled, no rejected:

TypeScript
export const fetchTodos = createAsyncThunk(
  "todos/fetch",
  async (userId, thunkAPI) => { /* ... */ },
  {
    condition: (userId, { getState }) => {
      const { todos } = getState() as RootState;
      if (todos.loading) return false; // already fetching
      return true;
    },
  },
);

This is the cleanest way to de-duplicate in-flight requests without writing a guard in every component.

.unwrap() - the dispatched thunk returns a promise that resolves to the action. Sometimes the component needs the actual fulfilled value (to navigate on success, to show an inline error). .unwrap() returns the payload directly, or rejects with the rejection value:

TypeScript
const handleSubmit = async () => {
  try {
    const todos = await dispatch(fetchTodos(userId)).unwrap();
    navigate(`/users/${userId}/todos/${todos[0].id}`);
  } catch (err) {
    toast.error("Couldn't load todos");
  }
};

That said - and this is the part you should internalise before you write your tenth thunk - createAsyncThunk is the right tool for one-off side effects. For server state (lists, details, mutations, anything that has a cache lifecycle), RTK has something better.

RTK Query: The Server-State Layer You Probably Don't Realise You Wanted

The most common use of Redux is, somewhat embarrassingly, "store the response from an API and a loading flag". People shipped countless slices that all had the same three reducers and the same three action types, varying only by URL.

RTK Query - which ships inside @reduxjs/toolkit since 1.6 - eliminates that pattern. You describe your API once, and it generates everything: the slice, the thunks, the cache, the hooks, the invalidation, the polling, the optimistic updates.

TypeScript services/api.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const api = createApi({
  reducerPath: "api",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Todo"],
  endpoints: (builder) => ({
    getTodos: builder.query<Todo[], string>({
      query: (userId) => `/users/${userId}/todos`,
      providesTags: (result) =>
        result
          ? [...result.map(({ id }) => ({ type: "Todo" as const, id })), { type: "Todo", id: "LIST" }]
          : [{ type: "Todo", id: "LIST" }],
    }),
    addTodo: builder.mutation<Todo, { userId: string; text: string }>({
      query: ({ userId, text }) => ({
        url: `/users/${userId}/todos`,
        method: "POST",
        body: { text },
      }),
      invalidatesTags: [{ type: "Todo", id: "LIST" }],
    }),
  }),
});

export const { useGetTodosQuery, useAddTodoMutation } = api;

Then in the component:

TSX
function TodoList({ userId }: { userId: string }) {
  const { data, isLoading, error } = useGetTodosQuery(userId);
  const [addTodo, { isLoading: isAdding }] = useAddTodoMutation();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorBox error={error} />;
  return (
    <>
      {data!.map((t) => <TodoRow key={t.id} todo={t} />)}
      <button onClick={() => addTodo({ userId, text: "New" })} disabled={isAdding}>
        Add
      </button>
    </>
  );
}

Notice what you are not writing: no loading flag in state, no error field, no useEffect that fires the fetch, no manual refetch after mutation. The hook handles all of it.

A few mechanics worth understanding so you don't fight the cache later:

Cache keys are derived from query arg + endpoint name. useGetTodosQuery("alice") and useGetTodosQuery("bob") are two separate cache entries. Two components calling useGetTodosQuery("alice") deduplicate to a single network request and share the result.

Tag-based invalidation is the heart of it. A query providesTags: [{ type: "Todo", id: "LIST" }] and a mutation invalidatesTags: [{ type: "Todo", id: "LIST" }] form a contract: when the mutation succeeds, every cached query with that tag is refetched. You can be finer-grained per-entity ({ type: "Todo", id: 7 }) so editing one todo refetches only the queries that include todo 7.

Cache eviction is timer-based. When the last subscriber to a cache entry unmounts, RTK Query starts a timer; if no new subscriber appears before it fires, the entry is dropped. The default is 60 seconds (the keepUnusedDataFor option, in seconds). Tune per-endpoint or per-app. Set it to 0 for endpoints whose data is sensitive or huge; bump it to 300 for endpoints that are expensive and rarely change.

Under the hood, RTK Query is built on top of createAsyncThunk and a normalised cache slice you can inspect in Redux DevTools. That last part matters more than people give it credit for. Compared to React Query or SWR - both excellent - RTK Query keeps its cache inside the Redux store, which means:

  • One DevTools timeline for all state, server + client.
  • Easy persistence with redux-persist for offline-first apps.
  • Cross-tab sync via redux-state-sync "just works".
  • Selectors can mix server cache and local UI state in one memoised computation.

The tradeoff is bundle size (RTK Query is ~9KB gzipped on top of RTK itself, vs React Query's standalone footprint) and a slightly steeper conceptual ramp because you're learning Redux concepts whether you wanted to or not. For an app that's already using Redux for client state, it's a no-brainer; for a fresh app with no need for shared client state, React Query or SWR are still completely defensible picks.

createEntityAdapter: The Normalised List Helper

One bonus that pairs well with both slices and RTK Query: createEntityAdapter. It is a small factory that gives you sorted, normalised collections ({ ids: [], entities: {} }) with prebuilt reducer cases and selectors.

TypeScript
import { createEntityAdapter, createSlice } from "@reduxjs/toolkit";

const todosAdapter = createEntityAdapter<Todo>({
  sortComparer: (a, b) => a.text.localeCompare(b.text),
});

const todosSlice = createSlice({
  name: "todos",
  initialState: todosAdapter.getInitialState({ loading: false }),
  reducers: {
    addOne: todosAdapter.addOne,
    updateOne: todosAdapter.updateOne,
    removeOne: todosAdapter.removeOne,
    setAll: todosAdapter.setAll,
  },
});

export const { selectAll, selectById, selectIds } =
  todosAdapter.getSelectors((state: RootState) => state.todos);

You stop writing state.items.find(t => t.id === id) and start writing selectById(state, id). For lists with frequent updates by id this is a real perf win - array find is O(n), the adapter's entities map is O(1).

The 2.0 Migration Cliffs

If you're on RTK 1.x and considering an upgrade, the breaking changes from the 2.0 release notes are mostly cleanup, but a couple bite:

  • extraReducers object form is gone. Migrate to builder.addCase chains. An official codemod (@reduxjs/rtk-codemods) handles the common conversions, but it won't catch every edge case, so plan to finish the last few slices by hand.
  • createReducer's object form is gone too - same builder-only rule, same codemod.
  • createStore from redux core is now deprecated. It still works (the Redux team has said it won't ever be removed), but your editor will show it struck through to nudge you toward configureStore. If you want the strikethrough gone without migrating, import it as legacy_createStore. Most apps using configureStore won't notice either way.
  • UMD builds dropped. ESM + CJS only. If you load Redux via a <script> tag from a CDN you'll need to migrate to a real bundler - which you should have done years ago.
  • combineSlices is new and worth a look for code-split apps: it lets feature modules call .inject(slice) when they load, so you don't have to enumerate every slice in your root reducer up front.

The migration is mostly mechanical and the official migration guide covers each case with diff-style examples.

What RTK Actually Changed

Strip away every API call, every option, every helper, and what RTK changed is the posture of Redux. The old library was a set of unopinionated primitives, and the community spent five years discovering, document, and re-discovering the same patterns on top of it - action constants, slice reducers, thunks, immutable helpers, normalised state, DevTools wiring, server-cache flags. RTK takes the patterns that won and bakes them into the library, in the same way TypeScript's standard lib bakes in array methods you'd otherwise polyfill.

The code you write today is shorter, harder to break, easier to type-check, and reads more like the business logic underneath. The store, the reducers, the DevTools, the time-travel debugging, the predictable single-source-of-truth contract - all the things people actually liked about Redux - are unchanged. The parts everyone complained about are mostly gone.

If you've been avoiding Redux because of what it felt like in 2017, the right move isn't to keep avoiding it. It's to open a fresh project, type npm install @reduxjs/toolkit react-redux, and write one slice. The 2017 version of you would not recognise the file.