Redux Toolkit — 대규모 앱의 상태관리
Redux Toolkit(RTK)은 Redux의 공식 도구 모음으로, 보일러플레이트를 대폭 줄여줍니다.
"Redux는 코드가 너무 많다"는 비판에 대한 답이 Redux Toolkit입니다. createSlice로 action과 reducer를 한 번에 정의하고, Immer를 내장하여 불변성 코드를 간결하게 작성할 수 있습니다.
설치
npm install @reduxjs/toolkit react-redux
npm install -D @types/react-redux
기본 구조
Slice 정의
// store/slices/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
// Immer 내장 — 직접 변경해도 불변성 유지됨
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
reset: () => initialState,
},
});
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
export default counterSlice.reducer;
Store 설정
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import authReducer from './slices/authSlice';
import cartReducer from './slices/cartSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
auth: authReducer,
cart: cartReducer,
},
});
// 타입 추론
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
타입 안전한 Hook
// store/hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './index';
// 앱 전체에서 이 Hook 사용 (타입 자동 추론)
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Provider 설정
// App.tsx
import { Provider } from 'react-redux';
import { store } from './store';
export default function App() {
return (
<Provider store={store}>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
</Provider>
);
}
컴포넌트에서 사용
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { increment, decrement, reset } from '../store/slices/counterSlice';
function CounterScreen() {
const count = useAppSelector((state) => state.counter.value);
const dispatch = useAppDispatch();
return (
<View style={styles.container}>
<Text style={styles.count}>{count}</Text>
<Pressable onPress={() => dispatch(increment())}>
<Text>+1</Text>
</Pressable>
<Pressable onPress={() => dispatch(decrement())}>
<Text>-1</Text>
</Pressable>
<Pressable onPress={() => dispatch(reset())}>
<Text>리셋</Text>
</Pressable>
</View>
);
}
비동기 처리 — createAsyncThunk
// store/slices/authSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
token: string | null;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
// 비동기 Thunk 정의
export const login = createAsyncThunk(
'auth/login',
async ({ email, password }: { email: string; password: string }, { rejectWithValue }) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
return rejectWithValue(error.message);
}
return await response.json(); // { user, token }
} catch (err) {
return rejectWithValue('네트워크 에러');
}
}
);
const authSlice = createSlice({
name: 'auth',
initialState: {
user: null,
token: null,
status: 'idle',
error: null,
} as AuthState,
reducers: {
logout: (state) => {
state.user = null;
state.token = null;
state.status = 'idle';
},
},
// 비동기 액션의 상태 변화 처리
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.status = 'succeeded';
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(login.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
});
export const { logout } = authSlice.actions;
export default authSlice.reducer;
사용
function LoginScreen() {
const dispatch = useAppDispatch();
const { status, error } = useAppSelector((state) => state.auth);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = () => {
dispatch(login({ email, password }));
};
return (
<View>
<TextInput value={email} onChangeText={setEmail} placeholder="이메일" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry />
{error && <Text style={{ color: 'red' }}>{error}</Text>}
<Pressable onPress={handleLogin} disabled={status === 'loading'}>
<Text>{status === 'loading' ? '로그인 중...' : '로그인'}</Text>
</Pressable>
</View>
);
}
장바구니 Slice 예제
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
}
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] } as CartState,
reducers: {
addItem: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
existing.quantity += 1; // Immer 덕분에 직접 변경 가능
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
updateQuantity: (state, action: PayloadAction<{ id: string; quantity: number }>) => {
const item = state.items.find((i) => i.id === action.payload.id);
if (item) {
item.quantity = action.payload.quantity;
}
},
clearCart: (state) => {
state.items = [];
},
},
});
// Selector 정의
export const selectCartTotal = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
export const selectCartItemCount = (state: RootState) =>
state.cart.items.reduce((sum, item) => sum + item.quantity, 0);
Redux Persist
import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { combineReducers } from 'redux';
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['auth', 'settings'], // 저장할 slice만 선택
};
const rootReducer = combineReducers({
auth: authReducer,
cart: cartReducer,
settings: settingsReducer,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
정리
- createSlice 로 action과 reducer를 한 번에 정의합니다
- Immer 내장으로 직접 상태를 변경하는 것처럼 코드를 작성할 수 있습니다
- createAsyncThunk 로 비동기 로직을 체계적으로 처리합니다
- 타입 안전한 커스텀 Hook(
useAppSelector,useAppDispatch)을 만들어 사용하세요 - 대규모 앱에서 여러 팀원이 협업할 때 ** 예측 가능한 상태 흐름 **이 Redux의 장점입니다
댓글 로딩 중...