Modern Redux with Redux Toolkit: Slices, Typed Hooks, Thunks, RTK Query and Entity Adapters

Key takeaways

Redux Toolkit is the official, opinionated, batteries-included toolset for efficient Redux development. It simplifies store setup, reduces boilerplate, and includes powerful utilities.

Introduction

Redux Toolkit (RTK) is the official, opinionated toolset for Redux. It includes utilities to simplify Redux development and enforces best practices.

Why Redux Toolkit Exists

Redux itself was never complicated in concept — a single store, pure reducer functions, and actions that describe what happened. The pain was always in the ceremony required to use it safely. Classic Redux forced every team to hand-roll the same three things over and over: string action type constants (and the inevitable typo when a dispatch call and a case statement drift apart), action creator boilerplate, and — the worst offender — manual immutable updates. Spreading nested objects correctly ({ ...state, user: { ...state.user, profile: { ...state.user.profile, name } } }) is exactly the kind of repetitive, error-prone code that a computer should be doing for you, not a human. Forget one spread and you mutate the state tree directly, which silently breaks reference-equality checks that useSelector, connect, and Redux DevTools’ time-travel debugging all depend on. The bug doesn’t crash anything — a component just quietly fails to re-render, and you burn an afternoon before realizing why.

RTK was built by the Redux team specifically to close that gap between “the ideas behind Redux are good” and “writing Redux by hand is miserable.” It bundles four opinionated defaults on top of the low-level Redux API: configureStore (sane middleware and DevTools setup out of the box, including the redux-thunk middleware for async logic), createSlice (a single function that generates action types, action creators, and a reducer together, so they can never drift apart), createAsyncThunk (a standardized way to dispatch pending/fulfilled/rejected actions for a promise-based side effect), and RTK Query (a data-fetching and caching layer that eliminates the need to hand-write loading/error/success reducers for server state at all). None of this changes what Redux is — it changes how much code you have to write to use it correctly, and it removes entire categories of bugs by construction rather than by discipline.

Classic Redux (Old Way)

// Action types
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

// Action creators
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });

// Reducer
function counterReducer(state = { value: 0 }, action) {
  switch (action.type) {
    case INCREMENT:
      return { value: state.value + 1 };
    case DECREMENT:
      return { value: state.value - 1 };
    default:
      return state;
  }
}

// Store
const store = createStore(counterReducer);

Redux Toolkit (Modern Way)

import { createSlice, configureStore } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => { state.value += 1 },
    decrement: state => { state.value -= 1 },
  },
});

export const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

Much simpler — but notice something that should look alarming if you know Redux’s rules: state.value += 1 directly mutates the argument, and plain Redux reducers are supposed to be pure functions that never mutate their input. This isn’t a bug or an exception carved out for convenience; it’s the core trick that makes createSlice work, and understanding it is the single most important thing to internalize before writing real RTK code.

How Immer Makes “Mutation” Safe

createSlice wraps every reducer function you write with Immer, a library that implements structural sharing via JavaScript Proxy objects. When your reducer runs, Immer doesn’t hand you the real state object — it hands you a Proxy that looks and behaves like the real object but actually records every property access and assignment you make. Internally, Immer builds a draft tree. As you write state.value += 1 or state.items.push(x), Immer intercepts those operations, and only at the end of the reducer does it produce a brand-new state object, copying over any branches of the tree you touched and reusing (by reference) every branch you didn’t touch. That’s structural sharing: if your state has { counter, user, cart } and your reducer only touches counter, the returned state has a new counter object but the exact same user and cart object references as before.

This matters enormously for performance and correctness. React-Redux’s useSelector (and Redux DevTools’ change detection) works by comparing object references with ===, not deep equality — that’s what makes Redux fast at scale, because a reference comparison is O(1) regardless of how large the state tree is. If Immer copied the entire state tree on every update, you’d lose that optimization; if you mutated the original state tree in plain Redux, === would return true for everything and nothing would ever re-render, which is precisely the class of bug RTK is designed to make impossible.

The critical rule to remember: this mutation-safety only exists inside a createSlice reducer (or any function wrapped by produce from Immer directly). If you take a piece of state outside a reducer — in a useSelector result, in an RTK Query response, in a thunk before you dispatch — and mutate it there, you are mutating real, frozen (in development, RTK freezes state with Object.freeze to catch exactly this) or shared objects, and you will corrupt your application state or throw a runtime error. Immer’s superpower is scoped strictly to the reducer function boundary; treat data anywhere else as normal, read-only, immutable JavaScript.

Installation

npm install @reduxjs/toolkit react-redux

Store Setup

// store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';
import userReducer from './features/user/userSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// main.tsx
import { Provider } from 'react-redux';
import { store } from './store';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <Provider store={store}>
    <App />
  </Provider>
);

Creating Slices

// features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
};

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: state => {
      // Redux Toolkit uses Immer, so "mutating" code is safe
      state.value += 1;
    },
    decrement: state => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;

createSlice is doing three jobs at once that you’d otherwise write by hand: for each key in the reducers object, it auto-generates an action type string (namespaced as "counter/increment" using the name field, which is why slice names must be unique across your store — a collision silently overwrites another slice’s actions), an action creator function with the same name, and wires all of that into the reducer it exports as counterSlice.reducer. That’s why counterSlice.actions.increment and counterSlice.reducer can never fall out of sync the way hand-written ACTION_TYPE constants and switch statements could — they’re generated from the same object literal in the same function call.

The full round trip from a component calling dispatch(increment()) to the UI updating looks like this:

sequenceDiagram
    participant C as Component
    participant D as dispatch
    participant R as Slice Reducer
    participant I as Immer Draft
    participant S as Store
    participant U as useSelector

    C->>D: dispatch(increment())
    D->>R: run reducer(state, action)
    R->>I: state.value += 1\n(Proxy records the write)
    I-->>R: finalize draft
    R-->>S: return new state object\n(unchanged branches reused)
    S-->>U: notify subscribers
    U->>U: compare old vs new slice\nwith ===
    U-->>C: re-render only if reference changed

The key thing this diagram makes explicit: the store itself never mutates in place. configureStore always receives a new top-level state object from the root reducer, and useSelector decides whether to re-render a given component purely by comparing the specific slice of state it reads against the previous render’s value. That’s also why writing selectors that return a new object or array literal on every call (e.g. state => state.items.map(...)) defeats this optimization — more on that in the footguns section below.

Using in Components

import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../../store';
import { increment, decrement, incrementByAmount } from './counterSlice';

function Counter() {
  const count = useSelector((state: RootState) => state.counter.value);
  const dispatch = useDispatch<AppDispatch>();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
}

Typed Hooks

// hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
import { useAppSelector, useAppDispatch } from '../../hooks';

function Counter() {
  const count = useAppSelector(state => state.counter.value);
  const dispatch = useAppDispatch();
  
  return <div>{count}</div>;
}

Async Actions (Thunks)

Redux’s core loop — dispatch(action) → reducer computes new state — only knows how to handle plain, synchronous objects. It has no built-in concept of “fetch this from the network and update state when it resolves.” createAsyncThunk solves this by generating three related action creators from a single call: for the thunk below, fetchUser.pending, fetchUser.fulfilled, and fetchUser.rejected. When a component calls dispatch(fetchUser(userId)), RTK immediately dispatches the pending action synchronously, then awaits your async function; depending on whether it resolves or throws, it dispatches either fulfilled (with the return value as action.payload) or rejected (with the thrown error captured on action.error). This gives you a state machine with exactly three states to model in your reducer — loading, success, failure — instead of hand-rolling flags that are easy to get inconsistent (e.g. forgetting to reset error to null when a new request starts, which is precisely what the code below does correctly).

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

interface User {
  id: number;
  name: string;
}

export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: number) => {
    const res = await fetch(`/api/users/${userId}`);
    return res.json();
  }
);

interface UserState {
  entity: User | null;
  loading: boolean;
  error: string | null;
}

const initialState: UserState = {
  entity: null,
  loading: false,
  error: null,
};

export const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {},
  extraReducers: builder => {
    builder
      .addCase(fetchUser.pending, state => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.entity = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || 'Failed to fetch user';
      });
  },
});
function UserProfile({ userId }: { userId: number }) {
  const dispatch = useAppDispatch();
  const { entity: user, loading, error } = useAppSelector(state => state.user);
  
  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [dispatch, userId]);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return null;
  
  return <div>{user.name}</div>;
}

A subtlety worth calling out: action.error.message in the rejected case is only reliable for genuine thrown errors (network failures, thrown exceptions). If your API returns a 200 with an error payload in the body, or a 4xx/5xx you want to inspect the response body for, createAsyncThunk provides a second parameter to the payload creator — thunkAPI.rejectWithValue(customErrorData) — which lets you dispatch rejected with a typed, structured payload instead of relying on action.error. Skipping this is a common footgun: teams write try/catch inside the thunk, catch a non-2xx response, and then throw new Error(...), only to discover later that they need the response body (validation error details, an error code for a specific UI message) and action.error.message is just a generic string. Reach for rejectWithValue from the start if your API contract includes structured error bodies.

RTK Query

The createAsyncThunk pattern above works, but notice how much of it is server-state bookkeeping that has nothing to do with your application’s actual business logic: a loading flag, an error field, a place to store the fetched entity, and a useEffect to trigger the fetch on mount. Multiply that by every endpoint your app calls, and you’re writing (and testing, and maintaining) dozens of nearly-identical slices. Worse, plain thunks give you no answer to questions like: “if two components on screen both need the same user, should we fetch twice?” or “when a mutation succeeds, how do we know which cached queries are now stale and need to refetch?”

RTK Query is a purpose-built data-fetching and caching layer that answers exactly these questions, and it’s the reason most new RTK codebases in 2026 write very few hand-rolled thunks for server data — thunks remain the right tool for client-side async logic that isn’t “fetch and cache” (background timers, orchestrating multiple dispatches, WebSocket-driven flows), but for anything shaped like “call a REST or GraphQL endpoint and show the result,” RTK Query removes the boilerplate above entirely.

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

interface Post {
  id: number;
  title: string;
  body: string;
}

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: builder => ({
    getPosts: builder.query<Post[], void>({
      query: () => 'posts',
    }),
    getPost: builder.query<Post, number>({
      query: id => `posts/${id}`,
    }),
    createPost: builder.mutation<Post, Partial<Post>>({
      query: body => ({
        url: 'posts',
        method: 'POST',
        body,
      }),
    }),
  }),
});

export const { useGetPostsQuery, useGetPostQuery, useCreatePostMutation } = api;

Two things happen automatically once a component calls useGetPostsQuery(): RTK Query generates a cache key from the endpoint name and its argument (void here, so there’s exactly one cache entry for “all posts”), and it deduplicates requests — if three components on the page all call useGetPostsQuery() at the same time, RTK Query fires exactly one network request and shares the result and loading state across all three subscribers. When the last subscribed component unmounts, the cache entry is kept for a configurable grace period (keepUnusedDataFor, 60 seconds by default) before being garbage collected, so navigating back to a page you just left doesn’t trigger a redundant refetch.

The other half of the story is cache invalidation, and this is where the trade-off against manual thunks becomes concrete. With hand-written thunks, after a createPost mutation succeeds, you are responsible for remembering to dispatch another fetchPosts (or manually splice the new post into the list) — miss that call anywhere in the codebase and the UI silently shows stale data. RTK Query instead uses a declarative tags system: you annotate getPosts with providesTags: ['Post'] and createPost with invalidatesTags: ['Post'], and RTK Query automatically refetches every active query tagged 'Post' the moment the mutation resolves — no manual coordination required, and it’s impossible to forget because the relationship is declared once, next to the endpoint definition, rather than scattered across every call site that happens to mutate that data. For finer granularity than “invalidate everything tagged Post,” tags can carry an id ({ type: 'Post', id } / { type: 'Post', id: 'LIST' }), so updating one post only invalidates that post’s detail query and the list query, not every other post’s detail query that happens to be cached.

The trade-off is really about ownership of complexity: manual thunks give you full control over exactly when and how state updates, at the cost of writing and maintaining that control flow yourself; RTK Query gives up some of that fine-grained control in exchange for eliminating an entire class of “forgot to refetch” bugs, at the cost of learning its cache-key and tag-invalidation model. For CRUD-shaped REST/GraphQL APIs — the large majority of real apps — that trade is almost always worth it.

// store.ts
import { configureStore } from '@reduxjs/toolkit';
import { api } from './services/api';

export const store = configureStore({
  reducer: {
    [api.reducerPath]: api.reducer,
  },
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware().concat(api.middleware),
});
function PostsList() {
  const { data: posts, isLoading, error } = useGetPostsQuery();
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;
  
  return (
    <ul>
      {posts?.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

function CreatePost() {
  const [createPost, { isLoading }] = useCreatePostMutation();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await createPost({ title: 'New Post', body: 'Content...' });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <button type="submit" disabled={isLoading}>
        Create
      </button>
    </form>
  );
}

Normalized State with createEntityAdapter

The shopping cart example below stores items as a flat array, and for small lists that’s perfectly fine. But once a slice manages a larger collection — a list of users, posts, or products that gets looked up by ID from many places in the UI — array-shaped state starts costing you real performance and correctness. Finding, updating, or removing a specific item requires an O(n) scan (items.find(item => item.id === id)), and every time you update a nested field inside one array element, Immer has to walk into that array to find it. Compare that to a normalized shape — a Record<id, T> lookup table plus a separate array of IDs for ordering — where any single-item lookup, update, or removal is O(1).

// features/products/productsSlice.ts
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import type { RootState } from '../../store';

interface Product {
  id: number;
  name: string;
  price: number;
}

// sortComparer keeps `selectIds` / `selectAll` in a stable, predictable order
const productsAdapter = createEntityAdapter<Product>({
  sortComparer: (a, b) => a.name.localeCompare(b.name),
});

const productsSlice = createSlice({
  name: 'products',
  initialState: productsAdapter.getInitialState({ loading: false }),
  reducers: {
    // adapter methods generate correct Immer-safe reducer logic for you
    productAdded: productsAdapter.addOne,
    productsReceived: productsAdapter.setAll,
    productUpdated: productsAdapter.updateOne,
    productRemoved: productsAdapter.removeOne,
  },
});

// Adapter-generated selectors, rebound to this slice's location in the store
export const {
  selectAll: selectAllProducts,
  selectById: selectProductById,
  selectIds: selectProductIds,
} = productsAdapter.getSelectors((state: RootState) => state.products);

export const { productAdded, productsReceived, productUpdated, productRemoved } =
  productsSlice.actions;
export default productsSlice.reducer;

createEntityAdapter generates two things: the shape { ids: number[], entities: Record<number, Product> } (produced by getInitialState), and a matching set of CRUD reducer functions (addOne, addMany, setAll, updateOne, removeOne, upsertOne, and more) plus memoized selectors (selectAll, selectById, selectIds, selectTotal) generated via getSelectors. The reason this is preferable to writing the same Record-based logic by hand is that the update reducers (updateOne, upsertMany) correctly handle the Immer draft semantics for you — merging partial updates into an existing entity without accidentally replacing the whole object and losing other fields, and keeping the ids array in sync whenever an entity is added or removed. It’s the same “let the library do the repetitive, error-prone part” philosophy that motivated createSlice in the first place, just applied one level up, to collections instead of individual state trees.

Real-World Example: Shopping Cart

// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
}

const initialState: CartState = {
  items: [],
};

export const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
      const existing = state.items.find(item => item.id === action.payload.id);
      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
    },
    removeItem: (state, action: PayloadAction<number>) => {
      state.items = state.items.filter(item => item.id !== action.payload);
    },
    updateQuantity: (state, action: PayloadAction<{ id: number; quantity: number }>) => {
      const item = state.items.find(i => i.id === action.payload.id);
      if (item) {
        item.quantity = action.payload.quantity;
      }
    },
    clearCart: state => {
      state.items = [];
    },
  },
});

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

// Selectors
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartTotal = (state: RootState) =>
  state.cart.items.reduce((total, item) => total + item.price * item.quantity, 0);

export default cartSlice.reducer;
function Cart() {
  const items = useAppSelector(selectCartItems);
  const total = useAppSelector(selectCartTotal);
  const dispatch = useAppDispatch();
  
  return (
    <div>
      {items.map(item => (
        <div key={item.id}>
          <span>{item.name}</span>
          <span>${item.price}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={e => dispatch(updateQuantity({
              id: item.id,
              quantity: parseInt(e.target.value)
            }))}
          />
          <button onClick={() => dispatch(removeItem(item.id))}>Remove</button>
        </div>
      ))}
      <div>Total: ${total.toFixed(2)}</div>
      <button onClick={() => dispatch(clearCart())}>Clear Cart</button>
    </div>
  );
}

DevTools

Redux DevTools are automatically enabled with configureStore:

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
  // DevTools enabled by default in development
});

Install browser extension:

Selector, serializability and re-render traps

Memoize derived selectors with createSelector

// Reusable, but returns a new array on every call (see below)
export const selectActiveUsers = (state: RootState) =>
  state.users.filter(u => u.active);

// Usage
const activeUsers = useAppSelector(selectActiveUsers);

There’s a trap hiding in selectActiveUsers above: .filter() returns a new array on every single call, even when the underlying users data hasn’t changed. Because useSelector decides whether to re-render by comparing the selector’s return value with ===, a plain derived selector like this one causes the component to re-render on every dispatched action that touches this slice of the store — not just the ones that actually changed which users are active — since the new array reference never equals the previous one.

createSelector (re-exported from reselect through RTK) fixes this by memoizing: it only recomputes the output if its input selectors’ results changed by reference, and otherwise returns the previously cached array.

import { createSelector } from '@reduxjs/toolkit';

const selectUsers = (state: RootState) => state.users;

export const selectActiveUsers = createSelector(
  [selectUsers],
  users => users.filter(u => u.active)
);

Now selectActiveUsers only recomputes (and only returns a new array reference) when state.users itself has changed reference — meaning any other slice’s action, or an action that touches users but not membership in the active set, no longer forces a re-render of components subscribed to this selector. As a rule of thumb: any selector that does .map(), .filter(), .reduce(), object spreading, or otherwise constructs a new object/array should go through createSelector once a component using it appears anywhere on a frequently re-rendered part of the tree.

Non-serializable value warnings

configureStore’s default middleware includes a serializability check that logs a console error (in development only) whenever a non-plain-JavaScript value — a Date, a Map, a class instance, a Promise, a File object — ends up inside an action or the store. This isn’t RTK being pedantic: Redux DevTools’ time-travel debugging works by serializing the entire state tree to replay it later, and features like redux-persist need to serialize state to localStorage. A Date object silently breaks both. The fix is almost always to store the serializable primitive instead — an ISO string or a Unix timestamp instead of a Date, a plain object instead of a Map — and construct the rich object only where you consume it in a component. For the rare legitimate exception (e.g., storing a non-serializable value intentionally in one specific slice), you can narrow the check with serializableCheck: { ignoredPaths: ['some.path'] } rather than disabling it globally, which would silently reintroduce the entire class of bug for the rest of the app.

Selecting too much state in one hook

// Bad: re-renders whenever ANY field on `user` changes
const user = useAppSelector(state => state.user);

// Good: only re-renders when `name` specifically changes
const name = useAppSelector(state => state.user.name);

useSelector re-renders a component whenever its selector’s return value changes by reference. Selecting a large object (state.user) subscribes the component to every field on that object — a completely unrelated field update (say, lastLoginAt) forces a re-render of a component that only ever displays name. In large components with several such selectors, this compounds into visible jank on frequent updates. Selecting only the specific primitive fields a component actually renders — and using multiple small useSelector calls rather than one large one — keeps each subscription’s blast radius as small as possible, which matters far more as an app’s state tree and update frequency grow.


Frequently Asked Questions (FAQ)

Q. Should I fetch server data with createAsyncThunk or RTK Query?

A. If the data comes from an API and you mostly need caching, loading and error flags, and refetching after mutations, RTK Query does all of that without hand-written pending/fulfilled/rejected reducers. createAsyncThunk still fits one-off async workflows that are not really cached server state, such as a multi-step checkout that updates several slices. Mixing both for the same resource is a common mistake, because you end up with two copies of the data that drift apart.