React Redux Toolkit: Slices, Thunks and RTK Query (2026)

Redux Toolkit (RTK) is the official, opinionated way to write Redux. It eliminates boilerplate by providing createSlice (combines actions + reducer), createAsyncThunk (handles async flows), and RTK Query (a full data-fetching cache layer that rivals TanStack Query). If you're using Redux in 2026, RTK is the only way to do it.

Setup

npm install @reduxjs/toolkit react-redux
// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './slices/counterSlice'
import authReducer from './slices/authSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    auth: authReducer,
  },
})

// Infer types from the store
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

// main.tsx — wrap app with Provider
import { Provider } from 'react-redux'
import { store } from './store'

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

createSlice

// store/slices/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) {
      // Immer lets you write "mutating" code — it's actually 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
    },
    reset() {
      return initialState  // Return new state instead of mutating
    },
  },
})

// Export actions and reducer
export const { increment, decrement, incrementByAmount, setStep, reset } = counterSlice.actions
export default counterSlice.reducer

// Selectors — colocate with slice
export const selectCount = (state: RootState) => state.counter.value
export const selectStep = (state: RootState) => state.counter.step
// Component usage
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, selectCount } from './store/slices/counterSlice'
import type { AppDispatch, RootState } from './store'

// Typed hooks (define once, use everywhere)
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

function Counter() {
  const count = useAppSelector(selectCount)
  const dispatch = useAppDispatch()

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
    </div>
  )
}

createAsyncThunk

// store/slices/postsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'

interface Post { id: string; title: string; body: string }
interface PostsState {
  items: Post[]
  status: 'idle' | 'loading' | 'succeeded' | 'failed'
  error: string | null
}

// Async thunk — handles pending/fulfilled/rejected automatically
export const fetchPosts = createAsyncThunk(
  'posts/fetchAll',
  async (_, { rejectWithValue }) => {
    try {
      const res = await fetch('/api/posts')
      if (!res.ok) throw new Error('Server error')
      return await res.json() as Post[]
    } catch (err) {
      return rejectWithValue((err as Error).message)
    }
  }
)

export const createPost = createAsyncThunk(
  'posts/create',
  async (post: Omit<Post, 'id'>, { rejectWithValue }) => {
    const res = await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(post),
    })
    if (!res.ok) return rejectWithValue('Failed to create')
    return await res.json() as Post
  }
)

const postsSlice = createSlice({
  name: 'posts',
  initialState: { items: [], status: 'idle', error: null } as PostsState,
  reducers: {
    postUpdated(state, action: PayloadAction<Post>) {
      const idx = state.items.findIndex(p => p.id === action.payload.id)
      if (idx !== -1) state.items[idx] = action.payload
    },
  },
  extraReducers(builder) {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.status = 'loading'
        state.error = null
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.items = action.payload
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.payload as string
      })
      .addCase(createPost.fulfilled, (state, action) => {
        state.items.push(action.payload)
      })
  },
})

export const { postUpdated } = postsSlice.actions
export default postsSlice.reducer

TypeScript Setup

// hooks/redux.ts — typed hooks (create once, import everywhere)
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from '../store'

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

// Usage — no casting needed
const posts = useAppSelector(state => state.posts.items)
const dispatch = useAppDispatch()
dispatch(fetchPosts())  // Typed correctly

RTK Query

RTK Query is a full data-fetching and caching solution built into Redux Toolkit:

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

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

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token
      if (token) headers.set('authorization', `Bearer ${token}`)
      return headers
    },
  }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: (result) =>
        result
          ? [...result.map(({ id }) => ({ type: 'Post' as const, id })), 'Post']
          : ['Post'],
    }),
    getPost: builder.query<Post, string>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
})

export const { useGetPostsQuery, useGetPostQuery } = postsApi

// Add to store
export const store = configureStore({
  reducer: {
    [postsApi.reducerPath]: postsApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(postsApi.middleware),
})
// Usage — automatic loading/error states, caching, refetch on focus
function PostList() {
  const { data: posts, isLoading, isError, refetch } = useGetPostsQuery()

  if (isLoading) return <Spinner />
  if (isError) return <button onClick={refetch}>Retry</button>

  return <ul>{posts?.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

RTK Query Mutations

export const postsApi = createApi({
  // ...
  endpoints: (builder) => ({
    // ...queries...
    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (body) => ({ url: '/posts', method: 'POST', body }),
      invalidatesTags: ['Post'],  // Invalidates all Post cache entries
    }),
    updatePost: builder.mutation<Post, Post>({
      query: ({ id, ...body }) => ({ url: `/posts/${id}`, method: 'PUT', body }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
    deletePost: builder.mutation<void, string>({
      query: (id) => ({ url: `/posts/${id}`, method: 'DELETE' }),
      invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
})

export const { useCreatePostMutation, useUpdatePostMutation, useDeletePostMutation } = postsApi

// Usage
function CreatePostButton() {
  const [createPost, { isLoading }] = useCreatePostMutation()

  return (
    <button
      disabled={isLoading}
      onClick={() => createPost({ title: 'New Post', body: 'Content' })}
    >
      {isLoading ? 'Creating...' : 'Create Post'}
    </button>
  )
}

Middleware

// Logger middleware
const logger = (storeAPI) => (next) => (action) => {
  console.log('dispatching', action)
  const result = next(action)
  console.log('next state', storeAPI.getState())
  return result
}

export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .prepend(logger)           // Runs before default middleware
      .concat(postsApi.middleware),
})

DevTools

Redux DevTools work automatically with RTK. In the browser extension you can:

  • Inspect every dispatched action and its payload
  • See state diff before/after each action
  • Time-travel debug — jump to any previous state
  • Replay action sequences
// configureStore enables DevTools automatically in development
// Disable in production or customize:
export const store = configureStore({
  reducer: rootReducer,
  devTools: process.env.NODE_ENV !== 'production',
})