Setting up State Management (Redux Toolkit) for React application
Redux Toolkit (RTK) — official library for writing Redux logic. Eliminates classic Redux complaints: excessive boilerplate, manual Immer setup, need to write action creators manually. RTK makes Redux compact without losing predictability.
RTK ≠ Redux alternative. RTK is the right way to write Redux in 2024.
How RTK differs from bare Redux
| Aspect | Redux (without RTK) | Redux Toolkit |
|---|---|---|
| Creating actions | { type: 'cart/ADD_ITEM', payload } manually |
cartSlice.actions.addItem(payload) |
| Immutability | Manual (spread operator) | Via Immer — mutations in reducers allowed |
| Thunk | redux-thunk separate |
createAsyncThunk built-in |
| Selectors | reselect separate |
createSelector built-in |
| Server data | Custom code | RTK Query built-in |
| Store config | 20+ lines boilerplate | configureStore in one call |
Modern architecture with RTK
Feature-based structure: each domain area is separate directory with slice, selectors and API.
// features/auth/authSlice.ts
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
email: string;
role: 'admin' | 'manager' | 'viewer';
permissions: string[];
}
interface AuthState {
user: User | null;
token: string | null;
status: 'idle' | 'loading' | 'authenticated' | 'error';
error: string | null;
}
// createAsyncThunk generates pending/fulfilled/rejected actions automatically
export const login = createAsyncThunk(
'auth/login',
async (credentials: { email: string; password: string }, { rejectWithValue }) => {
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!res.ok) {
const err = await res.json();
return rejectWithValue(err.message);
}
return res.json() as Promise<{ user: User; token: string }>;
} catch {
return rejectWithValue('Network error');
}
}
);
export const authSlice = createSlice({
name: 'auth',
initialState: {
user: null,
token: localStorage.getItem('token'),
status: 'idle',
error: null,
} satisfies AuthState,
reducers: {
// Immer allows mutating state directly — it's immutable under the hood
logout(state) {
state.user = null;
state.token = null;
state.status = 'idle';
localStorage.removeItem('token');
},
updateProfile(state, action: PayloadAction<Partial<User>>) {
if (state.user) {
Object.assign(state.user, action.payload);
}
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.user = action.payload.user;
state.token = action.payload.token;
state.status = 'authenticated';
localStorage.setItem('token', action.payload.token);
})
.addCase(login.rejected, (state, action) => {
state.status = 'error';
state.error = action.payload as string;
});
},
});
export const { logout, updateProfile } = authSlice.actions;
RTK Query — API layer without boilerplate
// features/products/productsApi.ts
import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react';
import type { RootState } from '@/store';
const baseQuery = fetchBaseQuery({
baseUrl: import.meta.env.VITE_API_URL,
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) headers.set('Authorization', `Bearer ${token}`);
return headers;
},
});
// Automatic retry with exponential backoff
const baseQueryWithRetry = retry(baseQuery, { maxRetries: 2 });
export const productsApi = createApi({
reducerPath: 'productsApi',
baseQuery: baseQueryWithRetry,
tagTypes: ['Product', 'Category'],
endpoints: (builder) => ({
listProducts: builder.query<PaginatedResponse<Product>, ProductQuery>({
query: (params) => ({ url: '/products', params }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Product' as const, id })), 'Product']
: ['Product'],
// Response transformation for normalization
transformResponse: (raw: ApiResponse<Product[]>) => ({
items: raw.data,
total: raw.meta.total,
page: raw.meta.page,
}),
}),
createProduct: builder.mutation<Product, CreateProductDto>({
query: (body) => ({ url: '/products', method: 'POST', body }),
invalidatesTags: ['Product'],
// Optimistic update
async onQueryStarted(body, { dispatch, queryFulfilled }) {
const patchResult = dispatch(
productsApi.util.updateQueryData('listProducts', {}, (draft) => {
draft.items.unshift({ id: 'temp', ...body, createdAt: new Date().toISOString() });
draft.total += 1;
})
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
}),
});
export const { useListProductsQuery, useCreateProductMutation } = productsApi;
Middleware for cross-cutting concerns
// store/middleware/errorMiddleware.ts
import type { Middleware } from '@reduxjs/toolkit';
import { isRejectedWithValue } from '@reduxjs/toolkit';
import { toast } from 'sonner';
export const errorMiddleware: Middleware = () => (next) => (action) => {
if (isRejectedWithValue(action)) {
const message = (action.payload as any)?.message ?? 'Unknown error';
toast.error(message);
}
return next(action);
};
Implementation timeline
- Week 1: store setup, feature-slices for main domains, typing
- Week 2: RTK Query endpoints, component integration, optimistic updates
- Week 3: middleware (error handling, analytics), memoized selectors
- Week 4: unit-tests for reducers and selectors, action naming conventions documentation







