Redux Toolkit 튜토리얼 (2)
configureStore
// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export default configureStore({
reducer: {
counter: counterReducer,
},
});
스토어는 configureStore
를 이용하여 생성합니다. configureStore
는 리듀서를 전달하고, 리듀서에는 키와 값이 들어가는데 키는 state.counter
섹션을 의미하며 값은 디스패치될 때마다 담당하는 리듀서 함수를 의미합니다.
예를 들어 블로그 앱의 경우 다음과 같이 정의할 수 있습니다.
import { configureStore } from '@reduxjs/toolkit';
import usersReducer from '../features/users/usersSlice';
import postsReducer from '../features/posts/postsSlice';
import commentsReducer from '../features/comments/commentsSlice';
export default configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
},
});
또는 combineReducers
를 이용하여 하나의 rootReducer를 만들어 전달할 수도 있습니다.
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
});
const store = configureStore({
reducer: rootReducer,
});
Redux Slices
// features/counter/counterSlice.ts
import { createSlice } from '@reduxjs/toolkit';
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
위 리듀서 로직은 3개의 액션 타입이 존재합니다.
{type: "counter/increment"}
{type: "counter/decrement"}
{type: "counter/incrementByAmount"}
액션의 타입은 문자열입니다. createSlice
을 사용하여 slice의 name을 정의하고 리듀서 함수를 작성하면 액션 생성 함수가 자동으로 생성됩니다. createSlice
는 초기 상태 값을 전달해야 하므로 초기 상태가 정의되어 있어야 합니다.
Reducer 규칙
- 상태와 액션을 기반으로 새 상태 값만 계산해야 합니다.
- 불변성을 유지해야 하며, 기존 상태 값을 변경할 수 없습니다.
- 비동기 로직을 수행할 수 없습니다.
리덕스의 목표 중 하나는 예측 가능한 코드를 만드는 것입니다. 함수가 순수하지 않으면 예측하기 어려워집니다.
리듀서와 불변성
리듀서는 상태 값을 변경할 수 없습니다.
// ❌
state.value = 123;
// ✅
return {
...state,
value: 123,
};
리덕스에서 상태를 변경해서는 안 되는 이유는 다음과 같습니다.
- UI 최신 값 업데이트 버그가 발생되기 쉽습니다.
- 테스트 코드를 작성하기가 더 어려워집니다.
- dev tools을 이용하여 Time Travel Debugging(시간 여행 디버깅)을 사용할 수 없습니다.
그렇기 때문에 이전 리덕스에서는 스프레드 연산자나 메서드를 통해 상태를 복사하여 불변성을 유지하였는데 이렇게 되면 코드가 복잡해지는 단점이 존재합니다.
// before
function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue,
},
},
},
};
}
// after
function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue;
}
redux toolkit의 createSlice
는 내부에 immer 라이브러리를 사용하여 간편하게 불변성을 지킬 수 있습니다.
// features/counter/counterSlice.ts
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
increment
과 decrement
액션은 항상 1을 증감하기 때문에 action 매개변수가 필요 없지만, incrementByAmount
는 payload
를 통해 값을 전달받아 추가하는 로직이므로 action
매개변수가 추가되었습니다.
useSelector
useSelector
훅을 사용하면 스토어에서 상태를 추출할 수 있습니다. 액션이 디스패치되고 스토어가 업데이트될 때마다 useSelector
는 함수를 다시 실행하여 값을 비교합니다. 값이 다르면 컴포넌트는 새 값으로 다시 렌더링 됩니다.
// 'state' is of type 'unknown'.ts(18046)
const count = useSelector((state) => state.counter.value);
타입스크립트에서는 state.counter
가 추론되지 않아 에러가 발생하기 때문에 추가 작업이 필요합니다.
// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
}
이렇게 하면 정상적으로 counter.value
가 추론되어 타입 에러가 발생하지 않지만 매번 타입을 추가하기엔 번거롭기 때문에 커스텀 훅을 만들어 사용할 수 있습니다.
// features/Counter.tsx
export default function Counter() {
const count = useSelector((state: RootState) => state.counter.value);
...
}
// hooks/index.ts;
import { TypedUseSelectorHook, useSelector } from 'react-redux';
import type { RootState } from 'app/store';
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// features/Counter.tsx
export default function Counter() {
const count = useAppSelector((state) => state.counter.value);
...
}
위와 같이 useSelector
대신 useAppSelector
를 사용하면 타입을 추가로 지정할 필요가 없습니다.
useDispatch
useDispatch
는 dispatch 메서드를 제공하여 액션을 스토어에 전달할 수 있게 도와주는 훅입니다.
const dispatch = useDispatch();
다음과 같이 액션을 전달할 수 있습니다.
<button type="button" onClick={() => dispatch(increment())}>
+
</button>
useDispatch
는 타입스크립트 환경에서 타입 에러가 발생하지 않습니다.
하지만 Thunk를 사용하면 에러가 발생하는데 그 이유는 useDispatch
는 thunk 미들웨어에 대해 알지 못하기 때문에 타입을 추가로 지정해 줘야 합니다.
// app/store.ts
export type AppDispatch = typeof store.dispatch;
// 사용 시
const dispatch: AppDispatch = useDispatch();
useAppSelector
와 마찬가지로 훅을 만들어 편하게 사용할 수 있습니다.
// hooks/index.ts;
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from 'app/store';
type DispatchFunc = () => AppDispatch;
export const useAppDispatch: DispatchFunc = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Provider
useSelector
와 useDispatch
를 사용하여 스토어와 통신하려면 index.tsx
에서 <App />
컴포넌트를 Provider
로 감싸 스토어를 전달해야 합니다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import reportWebVitals from './reportWebVitals';
import App from './App';
import { store } from './app/store';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
);
Thunk로 비동기 로직 작성하기
지금까지 예시 코드는 모두 동기로 작성된 코드인데요. 애플리케이션에서 ajax를 이용하여 api에서 데이터를 가져올 수 있기 때문에 비동기 로직을 넣을 장소가 필요합니다.
thunk는 비동기 로직을 포함할 수 있는 특정 종류의 리덕스 함수입니다. thunk는 두 가지 함수를 사용하여 작성됩니다.
- dispatch와 getState를 인수로 받는 내부 thunk 함수
- 외부 생성자 함수는 thunk 함수를 생성하고 반환합니다.
export const incrementAsync = (amount) => (dispatch) => {
setTimeout(() => {
dispatch(incrementByAmount(amount));
}, 1000);
};
일반 액션처럼 dispatch(incrementAsync(5))
를 호출하면 비동기 코드가 실행되고 다른 액션을 디스패치할 수 있습니다.
thunk를 사용하려면 스토어에 redux-thunk 미들웨어를 추가해야 합니다. 하지만 redux toolkit의 configureStore
는 이미 초기 셋팅에 설정되어 있어 바로 사용할 수 있습니다. 서버에서 데이터를 가져오기 위한 예시 코드는 다음과 같습니다.
// features/counter/counterSlice.ts
const fetchUserById = (userId) => {
return async (dispatch, getState) => {
try {
const user = await userAPI.fetchById(userId);
dispatch(userLoaded(user));
} catch (err) {}
};
};