Redux Toolkit: Redux Without The Pain

SS Saurav Sitaula

Master Redux Toolkit (RTK) - the official, recommended way to write Redux. Learn createSlice for reducing boilerplate, createAsyncThunk for async operations, configureStore setup, Immer for immutable updates, and RTK Query for data fetching. Complete migration guide from vanilla Redux.

The Boilerplate Breaking Point

After months with Redux, I had a love-hate relationship with it. I loved:

  • Predictable state updates
  • DevTools time-travel
  • Clear data flow

I hated:

  • Seven files for a feature
  • Three action types for every async operation
  • Switch statements that went on forever
  • The constant spreading to maintain immutability

Then I discovered Redux Toolkit (RTK)—the official, opinionated toolset for Redux. The React team and Redux maintainers built it together. It’s not a different library; it’s Redux with the rough edges sanded off.

The Same Counter, Transformed

Remember the counter from last time? Action types file, action creators file, reducer file, store configuration…

Here’s the same thing with Redux Toolkit:

// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: {
    increment: (state) => {
      state.count += 1;  // "Mutating" is fine! (Immer handles it)
    },
    decrement: (state) => {
      state.count -= 1;
    },
    reset: (state) => {
      state.count = 0;
    },
    incrementByAmount: (state, action) => {
      state.count += action.payload;
    },
  },
});

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

That’s it. One file. Actions and reducer together. No action type constants. No switch statements.

And look at this:

state.count += 1;

I’m mutating state! In Redux! This should be illegal!

But it’s not. Redux Toolkit uses Immer under the hood. You write “mutating” code, and Immer produces immutable updates. It’s syntactic sugar that makes Redux actually pleasant to write.

Store Configuration Made Simple

Old Redux store setup:

import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from './reducers';

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;

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

Redux Toolkit:

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

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

configureStore automatically:

  • Combines reducers
  • Adds Redux Thunk middleware
  • Enables Redux DevTools
  • Adds development checks for common mistakes

Async Actions with createAsyncThunk

Remember the triple-action pattern for async operations? START, SUCCESS, ERROR? createAsyncThunk generates all three:

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

// This creates three action types automatically:
// - users/fetchUser/pending
// - users/fetchUser/fulfilled
// - users/fetchUser/rejected
export const fetchUser = createAsyncThunk(
  'users/fetchUser',
  async (userId) => {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: {
    data: null,
    loading: false,
    error: null,
  },
  reducers: {
    // Regular synchronous reducers here
    clearUser: (state) => {
      state.data = null;
    },
  },
  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;
      });
  },
});

export const { clearUser } = userSlice.actions;
export default userSlice.reducer;

Using it in a component:

import { useDispatch, useSelector } from 'react-redux';
import { fetchUser } from './userSlice';

function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const { data: user, loading, error } = useSelector(state => state.user);
  
  useEffect(() => {
    dispatch(fetchUser(userId));
  }, [dispatch, userId]);
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <Profile user={user} />;
}

Still some boilerplate for the loading/error states, but way better than manually creating six action types and three action creators.

Error Handling and Payload Customization

createAsyncThunk handles errors, but you can customize the behavior:

export const fetchUser = createAsyncThunk(
  'users/fetchUser',
  async (userId, { rejectWithValue }) => {
    try {
      const response = await fetch(`/api/users/${userId}`);
      
      if (!response.ok) {
        // Return a custom error payload
        return rejectWithValue({
          status: response.status,
          message: 'User not found',
        });
      }
      
      return response.json();
    } catch (error) {
      return rejectWithValue({
        message: error.message,
      });
    }
  }
);

// In the slice
.addCase(fetchUser.rejected, (state, action) => {
  state.loading = false;
  state.error = action.payload;  // Now contains our custom error object
});

Prepare Callbacks for Complex Actions

Sometimes you need to transform the payload before it hits the reducer:

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: {
      reducer: (state, action) => {
        state.push(action.payload);
      },
      prepare: (text) => ({
        payload: {
          id: nanoid(),  // Generate ID here, not in reducer
          text,
          completed: false,
          createdAt: new Date().toISOString(),
        },
      }),
    },
  },
});

// Usage
dispatch(addTodo('Buy milk'));
// Payload becomes: { id: 'abc123', text: 'Buy milk', completed: false, createdAt: '...' }

The prepare callback keeps reducers pure while handling ID generation and timestamps.

Entity Adapter: CRUD Made Easy

If you’re managing collections of items (users, posts, products), createEntityAdapter is magic:

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

const postsAdapter = createEntityAdapter({
  // Sort by date, newest first
  sortComparer: (a, b) => b.date.localeCompare(a.date),
});

const postsSlice = createSlice({
  name: 'posts',
  initialState: postsAdapter.getInitialState({
    loading: false,
    error: null,
  }),
  reducers: {
    // These are auto-generated CRUD operations!
    postAdded: postsAdapter.addOne,
    postUpdated: postsAdapter.updateOne,
    postRemoved: postsAdapter.removeOne,
    postsReceived: postsAdapter.setAll,
  },
});

// Auto-generated selectors
export const {
  selectAll: selectAllPosts,
  selectById: selectPostById,
  selectIds: selectPostIds,
} = postsAdapter.getSelectors(state => state.posts);

The adapter stores entities in a normalized format automatically:

{
  ids: ['post1', 'post2', 'post3'],
  entities: {
    'post1': { id: 'post1', title: 'First Post', ... },
    'post2': { id: 'post2', title: 'Second Post', ... },
    'post3': { id: 'post3', title: 'Third Post', ... },
  },
  loading: false,
  error: null,
}

Normalized state with zero effort.

TypeScript Support

Redux Toolkit has excellent TypeScript support. Here’s a typed slice:

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

interface CounterState {
  value: number;
}

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

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

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

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Full type inference. Autocomplete everywhere. Compile-time errors for wrong payloads.

Migration Strategy

I didn’t rewrite everything at once. Here’s how I migrated gradually:

Step 1: Replace createStore with configureStore

// Before
const store = createStore(rootReducer, applyMiddleware(thunk));

// After
const store = configureStore({ reducer: rootReducer });

This alone gives you DevTools and better defaults.

Step 2: Convert One Slice at a Time

// Old reducer file can coexist with new slices
const store = configureStore({
  reducer: {
    // New RTK slice
    counter: counterSlice.reducer,
    // Old reducer - still works!
    user: oldUserReducer,
    cart: oldCartReducer,
  },
});

Step 3: Convert Async Actions to createAsyncThunk

Start with the most-used async actions. The old thunks still work while you migrate.

Step 4: Add Entity Adapters for Collections

Convert list/detail views last. They benefit most from entity adapters.

RTK Query: The Next Level

Redux Toolkit also includes RTK Query—a powerful data fetching and caching solution:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUser: builder.query({
      query: (userId) => `users/${userId}`,
    }),
    getPosts: builder.query({
      query: () => 'posts',
    }),
    addPost: builder.mutation({
      query: (newPost) => ({
        url: 'posts',
        method: 'POST',
        body: newPost,
      }),
    }),
  }),
});

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

Using it:

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useGetUserQuery(userId);
  
  if (isLoading) return <Spinner />;
  if (error) return <Error />;
  return <Profile user={user} />;
}

Automatic caching, deduplication, background refetching, optimistic updates… It’s like React Query, but integrated with Redux.

My Current Setup

After migrating to RTK, my Redux setup looks like this:

src/
├── app/
│   ├── store.js
│   └── hooks.js         # Typed useAppDispatch, useAppSelector
├── features/
│   ├── auth/
│   │   └── authSlice.js
│   ├── cart/
│   │   └── cartSlice.js
│   └── posts/
│       └── postsSlice.js
└── services/
    └── api.js           # RTK Query API definition

Clean. Organized. Minimal boilerplate.

What I Wish I’d Known Earlier

  1. Start with Redux Toolkit, not vanilla Redux. The old tutorials teaching raw Redux first are outdated. RTK is the standard now.

  2. Immer is not magic—learn how it works. Understanding that Immer uses Proxies helps debug weird issues.

  3. Don’t fight the patterns. createSlice wants you to colocate actions and reducers. Embrace it.

  4. Entity adapters are underrated. Any time you have a list of items by ID, reach for createEntityAdapter.

  5. RTK Query might be all you need. For most apps, RTK Query eliminates the need for manual async thunks entirely.

  6. The Redux style guide exists. Read it. It codifies best practices.

When to Use Redux Toolkit vs Alternatives

After using RTK extensively, here’s my decision tree:

Use Redux Toolkit when:

  • You need global state with complex interactions
  • DevTools and time-travel debugging are valuable
  • Your team already knows Redux
  • You want one tool for state AND data fetching (RTK Query)

Consider alternatives when:

  • State is simple (use Context + useReducer)
  • You only need data fetching (use React Query or SWR)
  • You want less boilerplate and more flexibility (Zustand)
  • You’re building a small app (might not need global state at all)

The Journey Continues

Redux Toolkit transformed Redux from “necessary evil” to “genuinely pleasant.” The patterns are solid. The tooling is mature. The developer experience is excellent.

But I kept hearing about alternatives. Zustand. Jotai. Recoil. Teams abandoning Redux entirely. Were they onto something?

Time to explore the post-Redux world.


P.S. — I timed myself rewriting a feature from vanilla Redux to Redux Toolkit. Old version: 4 files, 180 lines. New version: 1 file, 45 lines. Same functionality. The best code is the code you don’t have to write.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism