Redux Toolkit Complete Guide | Modern Redux State Management
이 글의 핵심
Redux Toolkit is the official, opinionated, batteries-included toolset for efficient Redux development. It simplifies store setup, reduces boilerplate, and includes powerful utilities.
Introduction
Redux Toolkit (RTK) is the official, opinionated toolset for Redux. It includes utilities to simplify Redux development and enforces best practices.
Classic Redux (Old Way)
// Action types
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
// Action creators
const increment = () => ({ type: INCREMENT });
const decrement = () => ({ type: DECREMENT });
// Reducer
function counterReducer(state = { value: 0 }, action) {
switch (action.type) {
case INCREMENT:
return { value: state.value + 1 };
case DECREMENT:
return { value: state.value - 1 };
default:
return state;
}
}
// Store
const store = createStore(counterReducer);
Redux Toolkit (Modern Way)
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1 },
decrement: state => { state.value -= 1 },
},
});
export const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
Much simpler!
1. Installation
npm install @reduxjs/toolkit react-redux
2. Store Setup
// store.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,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// main.tsx
import { Provider } from 'react-redux';
import { store } from './store';
ReactDOM.createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<App />
</Provider>
);
3. Creating Slices
// features/counter/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 => {
// Redux Toolkit uses Immer, so "mutating" code is safe
state.value += 1;
},
decrement: state => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
4. Using in Components
import { useSelector, useDispatch } from 'react-redux';
import { RootState, AppDispatch } from '../../store';
import { increment, decrement, incrementByAmount } from './counterSlice';
function Counter() {
const count = useSelector((state: RootState) => state.counter.value);
const dispatch = useDispatch<AppDispatch>();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(decrement())}>-</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
</div>
);
}
5. Typed Hooks
// hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
import { useAppSelector, useAppDispatch } from '../../hooks';
function Counter() {
const count = useAppSelector(state => state.counter.value);
const dispatch = useAppDispatch();
return <div>{count}</div>;
}
6. Async Actions (Thunks)
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
interface User {
id: number;
name: string;
}
export const fetchUser = createAsyncThunk(
'user/fetchUser',
async (userId: number) => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
}
);
interface UserState {
entity: User | null;
loading: boolean;
error: string | null;
}
const initialState: UserState = {
entity: null,
loading: false,
error: null,
};
export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {},
extraReducers: builder => {
builder
.addCase(fetchUser.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.entity = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message || 'Failed to fetch user';
});
},
});
function UserProfile({ userId }: { userId: number }) {
const dispatch = useAppDispatch();
const { entity: user, loading, error } = useAppSelector(state => state.user);
useEffect(() => {
dispatch(fetchUser(userId));
}, [dispatch, userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return <div>{user.name}</div>;
}
7. RTK Query
// services/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Post {
id: number;
title: string;
body: string;
}
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => 'posts',
}),
getPost: builder.query<Post, number>({
query: id => `posts/${id}`,
}),
createPost: builder.mutation<Post, Partial<Post>>({
query: body => ({
url: 'posts',
method: 'POST',
body,
}),
}),
}),
});
export const { useGetPostsQuery, useGetPostQuery, useCreatePostMutation } = api;
// store.ts
import { configureStore } from '@reduxjs/toolkit';
import { api } from './services/api';
export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(api.middleware),
});
function PostsList() {
const { data: posts, isLoading, error } = useGetPostsQuery();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error!</div>;
return (
<ul>
{posts?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
function CreatePost() {
const [createPost, { isLoading }] = useCreatePostMutation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await createPost({ title: 'New Post', body: 'Content...' });
};
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={isLoading}>
Create
</button>
</form>
);
}
8. Real-World Example: Shopping Cart
// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
}
const initialState: CartState = {
items: [],
};
export const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
const existing = state.items.find(item => item.id === action.payload.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem: (state, action: PayloadAction<number>) => {
state.items = state.items.filter(item => item.id !== action.payload);
},
updateQuantity: (state, action: PayloadAction<{ id: number; quantity: number }>) => {
const item = state.items.find(i => i.id === action.payload.id);
if (item) {
item.quantity = action.payload.quantity;
}
},
clearCart: state => {
state.items = [];
},
},
});
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
// Selectors
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartTotal = (state: RootState) =>
state.cart.items.reduce((total, item) => total + item.price * item.quantity, 0);
export default cartSlice.reducer;
function Cart() {
const items = useAppSelector(selectCartItems);
const total = useAppSelector(selectCartTotal);
const dispatch = useAppDispatch();
return (
<div>
{items.map(item => (
<div key={item.id}>
<span>{item.name}</span>
<span>${item.price}</span>
<input
type="number"
value={item.quantity}
onChange={e => dispatch(updateQuantity({
id: item.id,
quantity: parseInt(e.target.value)
}))}
/>
<button onClick={() => dispatch(removeItem(item.id))}>Remove</button>
</div>
))}
<div>Total: ${total.toFixed(2)}</div>
<button onClick={() => dispatch(clearCart())}>Clear Cart</button>
</div>
);
}
9. DevTools
Redux DevTools are automatically enabled with configureStore:
export const store = configureStore({
reducer: {
counter: counterReducer,
},
// DevTools enabled by default in development
});
Install browser extension:
10. Best Practices
1. Use createSlice
Always use createSlice instead of manually writing reducers.
2. Normalize State
// Good: normalized
interface State {
users: { byId: Record<number, User>; allIds: number[] };
}
// Bad: nested
interface State {
users: User[];
}
3. Use Selectors
// Good: reusable selector
export const selectActiveUsers = (state: RootState) =>
state.users.filter(u => u.active);
// Usage
const activeUsers = useAppSelector(selectActiveUsers);
Summary
Redux Toolkit modernizes Redux:
- Less boilerplate with
createSlice - Immer integration for immutable updates
- Async actions with
createAsyncThunk - RTK Query for data fetching
- DevTools built-in
Key Takeaways:
- Use
configureStorefor setup - Create slices with
createSlice - Use typed hooks
- Handle async with thunks or RTK Query
- Leverage Redux DevTools
Next Steps:
- Try Zustand
- Learn Jotai
- Compare MobX
Resources: