Back to articles

Modern Redux with Redux Toolkit & RTK Query in 2025

Build Scalable React Apps with Modern State Management

October 17, 2025
Updated October 17, 2025
Modern Redux Toolkit architecture with RTK Query data fetching and TypeScript
react
redux
typescript
frontend
12 min read

If you were writing React around 2017 or 2018, you probably have some Redux-induced trauma. I certainly do. I remember staring at five different files just to add a simple counter increment: action types, action creators, a reducer with a massive switch statement, and maybe a thunk if I was feeling adventurous. It was… a lot.

Redux Toolkit (RTK) changed all of that. It’s now the official recommended way to write Redux, and honestly, it’s night and day compared to the old approach.

In this guide, I’ll show you how to build modern, scalable React applications using Redux Toolkit and RTK Query. We’ll cover everything from basic setup to advanced patterns, all with TypeScript examples.

note

Historical Context: If you’re curious about the “old way” of doing Redux (or maintaining a legacy codebase), check out my article on classic Redux patterns. However, that article was more about managing the code than actually writing it. This article focuses exclusively on working with the modern approach.

Why Redux Toolkit?

The Redux team didn’t just ignore the “Redux is dead” tweets. They actually fixed the problems. Redux Toolkit (RTK) isn’t just a wrapper; it’s a complete reimagining of how we write Redux. It bundles the best practices I used to have to manually configure for every single client project:

  • Simplified store setup with good defaults
  • createSlice eliminates action types and creators
  • Immer integration for “mutative” updates (actually immutable under the hood)
  • Redux Thunk built-in for async logic
  • RTK Query for data fetching and caching
  • TypeScript support out of the box

The result? You write less code, make fewer mistakes, and ship faster.

Setting Up Redux Toolkit

Let’s get this running. It’s way simpler than it used to be.

First, install the packages:

npm install @reduxjs/toolkit react-redux

Store Configuration

The old way required configuring middleware, dev tools, and enhancers manually. RTK’s configureStore does it all:

// src/store/index.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,
  },
  // Redux DevTools automatically enabled in development
  // Thunk middleware included by default
});

// Infer types for TypeScript
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

That’s it. Redux DevTools, thunk middleware, and immutability checks are all configured automatically.

Provider Setup

Wire it up to React the same way as always:

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';

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

Creating Slices: The Modern Way

A “slice” is a collection of Redux logic for a single feature. One file contains your initial state, reducers, and actions—no more spreading them across multiple files.

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

interface CounterState {
  value: number;
  step: number;
}

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

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      // Looks like mutation, but Immer makes it immutable
      state.value += state.step;
    },
    decrement: (state) => {
      state.value -= state.step;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    setStep: (state, action: PayloadAction<number>) => {
      state.step = action.payload;
    },
  },
});

// Auto-generated action creators
export const { increment, decrement, incrementByAmount, setStep } = counterSlice.actions;

// Export reducer for store
export default counterSlice.reducer;

Notice what’s missing? The boilerplate.

  • No const INCREMENT = 'INCREMENT' screaming at you.
  • No manual action creators.
  • No switch statements (good riddance).
  • No nervous ...state spreading to avoid accidental mutations.

RTK handles all of that. The “mutations” you see (state.value += state.step) are actually immutable updates thanks to Immer. It’s like having your cake and eating it too.

Using Slices in Components

TypeScript hooks for type safety:

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

// Typed versions of hooks
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

Then use them in components:

// src/components/Counter.tsx
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { increment, decrement, incrementByAmount } from '../store/features/counter/counterSlice';

export default function Counter() {
  const count = useAppSelector((state) => state.counter.value);
  const step = useAppSelector((state) => state.counter.step);
  const dispatch = useAppDispatch();

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

Async Logic with createAsyncThunk

For async operations (API calls, timers, etc.), use createAsyncThunk. It automatically dispatches pending/fulfilled/rejected actions:

// src/store/features/user/userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

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

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

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

// Async thunk for fetching user
export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: string) => {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) throw new Error('Failed to fetch user');
    return (await response.json()) as User;
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    logout: (state) => {
      state.currentUser = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.currentUser = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || 'Unknown error';
      });
  },
});

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

Use it in components:

// src/components/UserProfile.tsx
import { useEffect } from 'react';
import { useAppSelector, useAppDispatch } from '../store/hooks';
import { fetchUser } from '../store/features/user/userSlice';

export default function UserProfile({ userId }: { userId: string }) {
  const { currentUser, loading, error } = useAppSelector((state) => state.user);
  const dispatch = useAppDispatch();

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

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!currentUser) return null;

  return (
    <div>
      <h2>{currentUser.name}</h2>
      <p>{currentUser.email}</p>
    </div>
  );
}
tip

createAsyncThunk handles the lifecycle for you: it dispatches pending when the async function starts, fulfilled when it succeeds with the returned value, and rejected if it throws.

RTK Query: Data Fetching Powerhouse

Here’s the thing: most of what we used Redux for was just caching server state. We’d fetch data, stick it in the store, and write a bunch of logic to track isLoading, isError, and data.

RTK Query retires all of that manual work. It’s a data fetching and caching tool built right into the toolkit. If you’ve used React Query (TanStack Query), this will feel very familiar.

RTK Query provides:

  • Automatic caching and cache invalidation
  • Request deduplication
  • Polling and refetching
  • Optimistic updates
  • TypeScript code generation (from OpenAPI specs)

Setting Up an API

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

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

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

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' }),
  tagTypes: ['Post', 'User'],
  endpoints: (builder) => ({
    // Query endpoints (GET)
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    getPost: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (_result, _error, id) => [{ type: 'Post', id }],
    }),
    getUser: builder.query<User, number>({
      query: (id) => `/users/${id}`,
      providesTags: (_result, _error, id) => [{ type: 'User', id }],
    }),
    
    // Mutation endpoints (POST, PUT, DELETE)
    createPost: builder.mutation<Post, Partial<Post>>({
      query: (body) => ({
        url: '/posts',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['Post'],
    }),
    updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
      invalidatesTags: (_result, _error, { id }) => [{ type: 'Post', id }],
    }),
    deletePost: builder.mutation<void, number>({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (_result, _error, id) => [{ type: 'Post', id }],
    }),
  }),
});

// Auto-generated hooks
export const {
  useGetPostsQuery,
  useGetPostQuery,
  useGetUserQuery,
  useCreatePostMutation,
  useUpdatePostMutation,
  useDeletePostMutation,
} = api;

Add the API reducer and middleware to your store:

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

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
    [api.reducerPath]: api.reducer, // Add API reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware), // Add API middleware
});

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

Using RTK Query in Components

The generated hooks handle everything—loading states, caching, refetching:

// src/components/PostList.tsx
import { useGetPostsQuery, useDeletePostMutation } from '../store/services/api';

export default function PostList() {
  const { data: posts, isLoading, isError, error } = useGetPostsQuery();
  const [deletePost, { isLoading: isDeleting }] = useDeletePostMutation();

  if (isLoading) return <div>Loading posts...</div>;
  if (isError) return <div>Error: {error.toString()}</div>;

  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
          <button
            onClick={() => deletePost(post.id)}
            disabled={isDeleting}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

RTK Query automatically:

  • Caches the result (subsequent renders don’t refetch)
  • Deduplicates requests (if two components request the same data simultaneously, only one request fires)
  • Invalidates cache when you delete a post (triggers refetch)
  • Handles loading and error states
important

RTK Query replaces redux-saga for 90% of data fetching use cases. You only need saga if you have complex workflow orchestration, background tasks, or special retry logic.

Feature-Based Folder Structure

One mistake I remember from legacy codebases is organizing files by type (a folder for actions, a folder for reducers). I believe it was one of the most common ways from that time. Please don’t do that anymore.

Modern Redux is all about features. Keep everything related to a feature in one place. It makes the codebase so much easier to navigate when you’re jumping back into it after six months.

src/
├── store/
│   ├── index.ts              # Store configuration
│   ├── hooks.ts              # Typed hooks
│   ├── features/             # Feature slices
│   │   ├── counter/
│   │   │   └── counterSlice.ts
│   │   ├── user/
│   │   │   └── userSlice.ts
│   │   └── posts/
│   │       └── postsSlice.ts
│   └── services/             # RTK Query APIs
│       └── api.ts
├── components/
├── pages/
└── main.tsx

Each feature folder contains everything related to that feature: slice, selectors, types, and optionally any feature-specific components.

For larger features, you might expand further:

src/store/features/posts/
├── postsSlice.ts       # Slice definition
├── selectors.ts        # Memoized selectors
├── types.ts            # TypeScript types
└── thunks.ts           # Complex async thunks (if needed)

Selectors and Memoization

Selectors derive data from the store. For expensive computations, use createSelector from Reselect (built into RTK):

// src/store/features/posts/selectors.ts
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../../index';

// Simple selector (assuming state.posts has an 'items' array)
export const selectAllPosts = (state: RootState) => state.posts.items;

// Memoized selector - only recomputes when posts change
export const selectPublishedPosts = createSelector(
  [selectAllPosts],
  (posts) => posts.filter((post) => post.published)
);

// Memoized with multiple inputs
export const selectPostsByUser = createSelector(
  [selectAllPosts, (_state: RootState, userId: number) => userId],
  (posts, userId) => posts.filter((post) => post.userId === userId)
);

Use in components:

import { useAppSelector } from '../store/hooks';
import { selectPublishedPosts, selectPostsByUser } from '../store/features/posts/selectors';

export default function PublishedPosts({ userId }: { userId: number }) {
  // Memoized - only recalculates when posts change
  const publishedPosts = useAppSelector(selectPublishedPosts);
  
  // Pass arguments using a callback
  const userPosts = useAppSelector((state) => selectPostsByUser(state, userId));

  return <div>{/* render posts */}</div>;
}
tip

Selectors are critical for performance. They prevent unnecessary re-renders and expensive recalculations.

Advanced: Entity Adapters

For normalized state (think relational database), use createEntityAdapter. It provides CRUD operations and selectors out of the box:

// src/store/features/posts/postsSlice.ts
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit';

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

// Entity adapter
const postsAdapter = createEntityAdapter<Post>({
  selectId: (post) => post.id,
  sortComparer: (a, b) => b.id - a.id, // Sort by newest first
});

const initialState = postsAdapter.getInitialState({
  loading: false,
  error: null as string | null,
});

// Async thunk
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
  const response = await fetch('/api/posts');
  return (await response.json()) as Post[];
});

const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    postAdded: postsAdapter.addOne,
    postUpdated: postsAdapter.updateOne,
    postRemoved: postsAdapter.removeOne,
    postsReceived: postsAdapter.setAll,
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.loading = false;
        postsAdapter.setAll(state, action.payload);
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || 'Failed to fetch posts';
      });
  },
});

// Export actions
export const { postAdded, postUpdated, postRemoved, postsReceived } = postsSlice.actions;

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

export default postsSlice.reducer;

Entity adapters handle normalization automatically—no more hand-writing lookup logic.

When to Use Redux Toolkit vs. Other Solutions

RTK is great when you need:

  • Shared global state across many components
  • Complex state logic (multiple reducers, middleware)
  • Centralized data fetching and caching (RTK Query)
  • Time-travel debugging (Redux DevTools)

Consider alternatives when:

  • Simple local state: useState or useReducer
  • Server state only: React Query or SWR
  • Component tree state: React Context API
  • Form state: React Hook Form or Formik

Don’t reach for Redux just because it exists. Use it when the complexity justifies the abstraction.

The Bottom Line

Redux Toolkit transformed Redux from verbose and error-prone to elegant and productive. You write significantly less code, get better defaults, and maintain type safety with TypeScript.

  • Use configureStore for automatic setup with DevTools and middleware
  • Build slices with createSlice instead of manual actions/reducers
  • Handle async logic with createAsyncThunk
  • Replace most data fetching with RTK Query
  • Organize by feature, not file type
  • Use createSelector for derived state
  • Leverage createEntityAdapter for normalized data

If you’re starting a new React project that needs global state management, Redux Toolkit is the modern standard. And if you’re maintaining legacy Redux code, RTK offers a gradual migration path without a complete rewrite.

The Redux ecosystem has matured beautifully. It went from being the tool we loved to hate, to a tool While my nine-to-five does not have React anymore, I might actually enjoy using again. The patterns are clearer, the code is cleaner, and I would spend way less time debugging typos in action strings.

Now go forth and delete some boilerplate!

Continue Reading

Discover more insights and stories that you might be interested in.