Development

State Management with Redux Toolkit

Simplify state management in your React application using Redux Toolkit.

Plake
Mar 22, 20268 min read1,404 words
State Management with Redux Toolkit

Redux has a reputation for boilerplate, and that reputation was earned — the original Redux required action type constants, action creators, switch-case reducers, and manual middleware wiring. Redux Toolkit (RTK) eliminates all of that. It is now the official, recommended way to write Redux code, and it is genuinely enjoyable to use. This guide shows you how RTK works, when to reach for it, and the patterns that scale.

When to Use Redux

Before installing Redux, consider whether you need it. Redux is the right tool when:

  • State needs to be shared across many components at different nesting levels.
  • State transitions are complex — multiple fields change together, or transitions need to be audited.
  • You need time-travel debugging or reproducible state snapshots.
  • You are building a large application where multiple developers need to reason about shared state consistently.

For simpler cases — fetching data for a single page, toggling UI state, sharing state between close siblings — React’s built-in useState, useReducer, and useContext are often enough. Zustand or Jotai are lighter alternatives worth considering before Redux.

Installation

npm install @reduxjs/toolkit react-redux

Store Setup

The store is the single container for all your application state. configureStore wires up Redux DevTools and redux-thunk middleware automatically:

// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cartSlice";
import userReducer from "./userSlice";

export const store = configureStore({
  reducer: {
    cart: cartReducer,
    user: userReducer,
  },
});

// Inferred types — export these for use across the app
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Wrap your app:

// Providers.jsx — hydrate as an island with client:load
import { Provider } from "react-redux";
import { store } from "@/store";

export function Providers({ children }: { children: React.ReactNode }) {
  return <Provider store={store}>{children}</Provider>;
}

createSlice

A slice bundles state, reducers, and action creators for one feature into a single file. This is the main building block:

// store/cartSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

type CartItem = { id: string; name: string; price: number; quantity: number };
type CartState = { items: CartItem[]; coupon: string | null };

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

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

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

RTK uses Immer under the hood, so you can write mutating logic (state.items.push(...)) and Immer produces an immutable update. Never mutate state directly outside RTK — the mutations only work inside slice reducers.

Typed Hooks

Create typed versions of useSelector and useDispatch once, use everywhere:

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

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

Using Store in Components

// CartButton.jsx — an interactive island
import { useAppSelector, useAppDispatch } from "@/store/hooks";
import { addItem, removeItem } from "@/store/cartSlice";

export function CartButton({ product }) {
  const dispatch = useAppDispatch();
  const cartItems = useAppSelector((state) => state.cart.items);
  const count = cartItems.reduce((sum, item) => sum + item.quantity, 0);

  return (
    <div>
      <span>{count} items in cart</span>
      <button
        onClick={() =>
          dispatch(
            addItem({
              id: product.id,
              name: product.name,
              price: product.price,
              quantity: 1,
            }),
          )
        }
      >
        Add to Cart
      </button>
    </div>
  );
}

Selectors

Selectors are functions that derive data from the store. Centralise them in the slice file so component code doesn’t contain store structure knowledge:

// store/cartSlice.ts — add selectors at the bottom
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartCount = (state: RootState) =>
  state.cart.items.reduce((sum, item) => sum + item.quantity, 0);
export const selectCartTotal = (state: RootState) =>
  state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

For expensive derived data, use createSelector from Reselect (bundled with RTK) to memoize:

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

export const selectDiscountedTotal = createSelector(
  selectCartTotal,
  (state: RootState) => state.cart.coupon,
  (total, coupon) => {
    if (coupon === "SAVE20") return total * 0.8;
    return total;
  },
);

createSelector only recomputes when its input selectors return new values. Components subscribing to this selector don’t re-render when unrelated state changes.

Async Logic with createAsyncThunk

For async operations like API calls, use createAsyncThunk. It dispatches pending, fulfilled, and rejected actions automatically:

// store/userSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchUser = createAsyncThunk(
  "user/fetch",
  async (userId: string) => {
    const res = await fetch(`/api/users/${userId}`);
    if (!res.ok) throw new Error("Failed to fetch user");
    return res.json();
  },
);

const userSlice = createSlice({
  name: "user",
  initialState: {
    data: null as User | null,
    status: "idle" as "idle" | "loading" | "success" | "error",
    error: null as string | null,
  },
  reducers: {
    logout: (state) => {
      state.data = null;
      state.status = "idle";
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = "loading";
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = "success";
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = "error";
        state.error = action.error.message ?? "Unknown error";
      });
  },
});

In a component:

function UserProfile({ userId }: { userId: string }) {
  const dispatch = useAppDispatch();
  const { data: user, status, error } = useAppSelector((s) => s.user);

  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [dispatch, userId]);

  if (status === "loading") return <Spinner />;
  if (status === "error") return <p>Error: {error}</p>;
  if (!user) return null;
  return <div>{user.name}</div>;
}

RTK Query

For data fetching and caching specifically, RTK Query (part of RTK) eliminates most of the thunk/status boilerplate above. It generates hooks from an API definition:

// store/apiSlice.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const api = createApi({
  reducerPath: "api",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Post", "User"],
  endpoints: (builder) => ({
    getUser: builder.query<User, string>({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: "User", id }],
    }),
    getPosts: builder.query<Post[], void>({
      query: () => "/posts",
      providesTags: ["Post"],
    }),
    createPost: builder.mutation<Post, Partial<Post>>({
      query: (body) => ({ url: "/posts", method: "POST", body }),
      invalidatesTags: ["Post"], // auto-refetches posts list
    }),
  }),
});

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

Add to store:

import { api } from "./apiSlice";

export const store = configureStore({
  reducer: {
    [api.reducerPath]: api.reducer,
    cart: cartReducer,
  },
  middleware: (getDefault) => getDefault().concat(api.middleware),
});

Use in components — zero manual loading state management:

function Posts() {
  const { data: posts, isLoading, isError } = useGetPostsQuery();
  const [createPost, { isLoading: isCreating }] = useCreatePostMutation();

  if (isLoading) return <Spinner />;
  if (isError) return <p>Failed to load posts.</p>;

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

RTK Query handles deduplication, caching, background refetching, and cache invalidation. For most data-fetching use cases, it replaces useEffect + thunks entirely.

Redux state management illustration

Redux DevTools

Install the Redux DevTools browser extension. It gives you:

  • Action log — every dispatched action with its payload.
  • State diff — what changed in the store after each action.
  • Time travel — replay actions forward and backward.
  • Import/export state — reproduce a bug by importing a state snapshot.

configureStore enables DevTools automatically in development.

Folder Structure

store/
├── index.ts              # configureStore, RootState, AppDispatch
├── hooks.ts              # typed useAppSelector, useAppDispatch
├── apiSlice.ts           # RTK Query API
├── cartSlice.ts          # cart state + selectors
├── userSlice.ts          # user state + selectors
└── notificationsSlice.ts

Keep each slice focused on one domain. Export selectors from the slice file — not from index.ts — to keep the public API explicit.

Closing Thoughts

Redux Toolkit makes Redux the straightforward choice for complex shared state. createSlice eliminates boilerplate, Immer makes reducers readable, and RTK Query handles server state so cleanly that you may never write a loading spinner by hand again. The key is knowing when Redux is earning its complexity — for simple local state, stick with useState; for shared server data, RTK Query alone might be all you need.

Share:

About the author

The Plake Team

Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.

Read more

Keep reading

Related Articles

Back to AllBack to All
Frontend Testing Strategies with Jest and React Testing Library

Mar 12, 2026

7 min

Frontend Testing Strategies with Jest and React Testing Library

React Hooks: A Deep Dive into Modern React Development

Feb 25, 2026

8 min

React Hooks: A Deep Dive into Modern React Development

Mastering Tailwind CSS for Modern Web Design

Feb 20, 2026

6 min

Mastering Tailwind CSS for Modern Web Design