Redux Toolkit (RTK) is the official, opinionated way to write Redux logic. Let's master it from setup to advanced patterns.
Why Redux Toolkit?
Traditional Redux had too much boilerplate. Redux Toolkit simplifies:
- Less boilerplate: No more action creators, switch statements
- Immutability: Built-in Immer for easy state updates
- Async: Built-in createAsyncThunk for API calls
- TypeScript: First-class TypeScript support
Installation
bash
npm install @reduxjs/toolkit react-reduxBasic Setup
Configure Store
typescript
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
user: userReducer,
},
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;Provide Store
typescript
// main.tsx
import { Provider } from 'react-redux';
import { store } from './store';
ReactDOM.createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<App />
</Provider>
);Slices
Create Slice
typescript
// store/slices/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
// Immer makes this safe
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
reset: (state) => {
state.value = 0;
},
},
});
// Export actions
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
// Export reducer
export default counterSlice.reducer;
// Selectors
export const selectCount = (state: RootState) => state.counter.value;Using State in Components
Typed Hooks
typescript
// store/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Use throughout app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();In Component
typescript
// components/Counter.tsx
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { increment, decrement, incrementByAmount } from '../store/slices/counterSlice';
export function Counter() {
const count = useAppSelector((state) => state.counter.value);
const dispatch = useAppDispatch();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
<button onClick={() => dispatch(incrementByAmount(5))}>
Add 5
</button>
</div>
);
}Async Thunks
Create Async Thunk
typescript
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
users: User[];
loading: boolean;
error: string | null;
}
const initialState: UserState = {
users: [],
loading: false,
error: null,
};
// Async thunk
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await fetch('https://api.example.com/users');
const data: User[] = await response.json();
return data;
}
);
export const createUser = createAsyncThunk(
'users/createUser',
async (userData: Omit<User, 'id'>) => {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
const data: User = await response.json();
return data;
}
);
const userSlice = createSlice({
name: 'users',
initialState,
reducers: {
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
// Fetch users
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch users';
})
// Create user
.addCase(createUser.fulfilled, (state, action: PayloadAction<User>) => {
state.users.push(action.payload);
});
},
});
export const { clearError } = userSlice.actions;
export default userSlice.reducer;Using Async Thunks
typescript
// components/Users.tsx
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { fetchUsers } from '../store/slices/userSlice';
export function Users() {
const dispatch = useAppDispatch();
const { users, loading, error } = useAppSelector((state) => state.users);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Normalized State
Why Normalize?
Avoid data duplication and improve performance:
typescript
// ❌ BAD: Denormalized
interface BadState {
users: Array<{
id: number;
name: string;
posts: Array<{
id: number;
title: string;
comments: Array<{...}>
}>
}>;
}
// ✅ GOOD: Normalized
interface GoodState {
users: {
ids: number[];
entities: Record<number, { id: number; name: string; postIds: number[] }>;
};
posts: {
ids: number[];
entities: Record<number, { id: number; title: string; userId: number; commentIds: number[] }>;
};
comments: {
ids: number[];
entities: Record<number, { id: number; text: string; postId: number }>;
};
}Create Adapters
typescript
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
// Create adapter
const usersAdapter = createEntityAdapter<User>({
selectId: (user) => user.id,
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
// Get selectors
export const {
selectAll: selectAllUsers,
selectById: selectUserById,
selectIds: selectUserIds,
selectTotal: selectUserTotal,
} = usersAdapter.getSelectors();
// Create slice
const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({
loading: false,
error: null,
}),
reducers: {
addUser: usersAdapter.addOne,
updateUser: usersAdapter.updateOne,
removeUser: usersAdapter.removeOne,
setUsers: usersAdapter.setAll,
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.fulfilled, (state, action) => {
usersAdapter.setAll(state, action.payload);
});
},
});Middleware
Logger Middleware
typescript
// store/middleware/logger.ts
import { Middleware } from '@reduxjs/toolkit';
export const loggerMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
console.log('Dispatching:', action);
const result = next(action);
console.log('Next State:', store.getState());
return result;
};Custom Middleware
typescript
// store/middleware/analytics.ts
import { Middleware } from '@reduxjs/toolkit';
export const analyticsMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
// Track specific actions
if (action.type.startsWith('users/')) {
// Send to analytics
console.log('Analytics:', action.type);
}
return next(action);
};Add Middleware to Store
typescript
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { loggerMiddleware } from './middleware/logger';
import { analyticsMiddleware } from './middleware/analytics';
export const store = configureStore({
reducer: {
// ...reducers
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(loggerMiddleware)
.concat(analyticsMiddleware),
});Persistence
LocalStorage Persistence
typescript
// store/middleware/persistence.ts
import { Middleware } from '@reduxjs/toolkit';
export const persistenceMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
const result = next(action);
// Save to localStorage
const state = store.getState();
localStorage.setItem('reduxState', JSON.stringify(state));
return result;
};
// Load from localStorage
const loadState = () => {
try {
const serializedState = localStorage.getItem('reduxState');
if (serializedState) {
return JSON.parse(serializedState);
}
} catch (err) {
return undefined;
}
};
// Use in store configuration
export const store = configureStore({
preloadedState: loadState(),
// ...rest of config
});Best Practices
1. Use Slices
Organize state by feature:
typescript
// store/
// index.ts
// slices/
// authSlice.ts
// usersSlice.ts
// postsSlice.ts
// middleware/
// logger.ts
// analytics.ts2. Normalize State
Use entity adapters for collections:
typescript
const usersAdapter = createEntityAdapter<User>();3. Type Everything
Leverage TypeScript:
typescript
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();4. Handle Async Properly
Use createAsyncThunk:
typescript
export const fetchData = createAsyncThunk(
'data/fetch',
async (params: { id: string }) => {
const response = await fetch(`/api/data/${params.id}`);
return response.json();
}
);5. Selectors for Derived Data
Create memoized selectors:
typescript
import { createSelector } from '@reduxjs/toolkit';
export const selectActiveUsers = createSelector(
[(state: RootState) => state.users.entities],
(users) => Object.values(users).filter(user => user.active)
);Performance Optimization
Memoized Selectors
typescript
import { createSelector } from '@reduxjs/toolkit';
export const selectUserPosts = createSelector(
[(state: RootState) => state.users.entities,
(state: RootState) => state.posts.entities,
(state: RootState, userId: number) => userId],
(users, posts, userId) => {
const user = users[userId];
if (!user) return [];
return user.postIds.map(postId => posts[postId]);
}
);Lazy Loading Slices
typescript
// store/index.ts
export const store = configureStore({
reducer: {
// Eagerly loaded
auth: authReducer,
// Lazy loaded
users: (state, action) => {
if (action.type === 'users/inject') {
return usersReducer(state, action);
}
return state || {};
},
},
});
// Inject slice later
import { injectUsersReducer } from './store/slices/usersSlice';
injectUsersReducer();Testing
Test Slice
typescript
// tests/slices/counterSlice.test.ts
import reducer, { increment, incrementByAmount } from '../counterSlice';
describe('counterSlice', () => {
const initialState = { value: 0 };
it('should handle initial state', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual(initialState);
});
it('should handle increment', () => {
const actual = reducer(initialState, increment());
expect(actual.value).toEqual(1);
});
it('should handle incrementByAmount', () => {
const actual = reducer(initialState, incrementByAmount(5));
expect(actual.value).toEqual(5);
});
});Test Async Thunk
typescript
// tests/thunks/fetchUsers.test.ts
import { fetchUsers } from '../userSlice';
import { configureStore } from '@reduxjs/toolkit';
describe('fetchUsers', () => {
it('should fetch users successfully', async () => {
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([{ id: 1, name: 'John' }]),
})
) as jest.Mock;
const store = configureStore({
reducer: {
users: userReducer,
},
});
await store.dispatch(fetchUsers());
const state = store.getState();
expect(state.users.users).toEqual([{ id: 1, name: 'John' }]);
expect(state.users.loading).toBe(false);
});
});Common Patterns
Optimistic Updates
typescript
export const updateUser = createAsyncThunk(
'users/update',
async (user: User, { dispatch }) => {
// Optimistic update
dispatch(userSlice.actions.updateUser(user));
try {
const response = await fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(user),
});
return await response.json();
} catch (error) {
// Rollback on error
dispatch(userSlice.actions.revertUpdate(user));
throw error;
}
}
);Cache Invalidation
typescript
export const invalidateCache = () => ({
type: 'cache/invalidate',
payload: Date.now(),
});
const cacheSlice = createSlice({
name: 'cache',
initialState: {
lastInvalidated: null,
},
reducers: {
invalidate: (state) => {
state.lastInvalidated = Date.now();
},
},
extraReducers: (builder) => {
builder
.addCase(createUser.fulfilled, (state) => {
state.lastInvalidated = Date.now();
});
},
});Conclusion
Redux Toolkit simplifies Redux significantly. Use slices for organization, createAsyncThunk for async, and entity adapters for collections. Type everything and use memoized selectors for performance. The result is clean, maintainable state management.