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.
Table of Contents
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',
})