지난 글에서 Redux와 Redux Toolkit의 차이점에 대해 알아보았다.
오늘은 리덕스 툴킷의 API에 대해 더 자세히 알아보자.
Slice
지난 글에서 store를 구성하는 방법에 대해 알아보았다.
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit'
const initialState = { value: 0 }
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value++
},
decrement(state) {
state.value--
},
incrementByAmount(state, action) {
state.value += action.payload
},
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
// store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
Slice란 쉽게 생각해서 각 기능별로 store를 슬라이스하는 것이라 생각하면 된다.
예를 들어 Todo List 앱을 개발한다고 가정해보자.
1) 로그인 기능 2) Todo list를 보여주는 기능이 있다고 가정했을 때 state를 크게 1) 로그인과 연관된 state 2) Todo list와 연관된 state로 나누는 것이다.
import { configureStore } from '@reduxjs/toolkit';
import loginSlice from './features/login/loginSlice';
import todoSlice from './features/todo/todoSlice';
export const store = configureStore({
reducer: {
login: loginReducer,
todo: todoReducer,
},
});
Slice 파일을 위한 가장 흔한 코딩컨벤션은 src/features
폴더에 저장하는 방법이 있다.
Reducer, Action
reducer
란 action type
에 따라 state
를 컨트롤하는 함수라고 생각하면 된다.
// ...
reducers: {
increment: (state) => {
state.value++
},
decrement: (state) => {
state.value--
},
incrementByAmount: (state, action) => {
state.value += action.payload
},
},
// ...
slice를 생성할 때 위와 같이 reducers
프로퍼티 안에서 reducer를 정의하며,
reducer를 생성하면 action creator가 reducer와 같은 이름으로 action(increment, decrement 등)을 자동 생성한다.
컴포넌트에서 action을 사용해보자.
아래와 같이 'react-redux' 의 useDispatch
를 사용해 dispatch를 정의하고 action을 dispatch하면, 그에 맞는 reducer가 실행된다.
// App.js
// ...
import { useDispatch } from 'react-redux';
import { increment } from '../features/counter/counterSlice'
function App() {
const dispatch = useDispatch();
// ...
return (
<div>
<button onClick={() => dispatch(increment())}>increment</button>
</div>
)
}
또한 Redux Toolkit에서는 immer 라이브러리를 포함하기 때문에 state를 mutating 하는 것이 가능하다.
// ...
reducers: {
increment: (state) => {
state.value++ // reducer는 항상 새로운 state를 반환하므로 mutating 가능
},
},
// ...
컴포넌트에서 State에 접근하기
그렇다면 컴포넌트에서 Store에 저장된 state에는 어떻게 접근할까?
마찬가지로 'react-redux'의 useSelector
를 사용하면 된다.
useSelector는 첫 번째 인자로 store
자체를 넘겨주고, 이 store를 통해 slice의 state에 접근할 수 있다.
// ...
import { useSelector } from 'react-redux';
function App() {
const { count } = useSelector((store) => store.counter);
// ...
}
Thunk
일반적으로 reducer는 sync function만 사용가능하다.
그래서 API에서 데이터를 불러오는 기능을 reducer로 구현하려고 하면 구현할 수 없다. (API에서 데이터를 불러오기 위해서는 asyncronous하게 구현해야하기 때문)
이를 위해 제공되는 것이 createAsyncThunk
이다.
createAsyncThunk
는 action type과 콜백 함수를 받아 Promise를 반환하는 함수이다.
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus', // action type
async (userId, thunkAPI) => { // callback function
const response = await userAPI.fetchById(userId)
return response.data
}
)
이렇게 생성된 함수는 Slice를 생성할 때 extraReducers
프로퍼티를 추가하며, createAsyncThunk
는 Promise의 라이프사이클을 반환하기 때문에 각 라이프 사이클별로 action type을 생성한다.
위 경우에는 user/requestStatus
의 action type으로 pending
, fulfilled
, rejected
를 생성한다.
- pending:
users/requestStatus/pending
- fulfilled:
users/requestStatus/fulfilled
- rejected:
users/requestStatus/rejected
// ...
const usersSlice = createSlice({
// ...
extraReducers: (builder) => {
builder.addCase(fetchUserById.fulfilled, (state, action) => {
state.entities.push(action.payload)
})
},
})
컴포넌트에서 해당 reducer를 사용할 때는 일반 reducer처럼 사용하면 된다.
dispatch(fetchUserById(123))
thunkAPI
createAsyncThunk
에서 콜백 함수를 생성할 때 두 가지 arguments가 주어진다.
첫번째는 넘겨줄 값(인자) 이고, 두번째는 thunkAPI
이다.
thunkAPI
란 thunk 함수에 전달되는 모든 파라미터를 포함하는 객체를 말한다.
여기에서 주목할 점은 thunk 함수에 사용할 store 전체의 state에 접근할 수 있는 getState
와 dispatch
파라미터를 포함한다는 것이다.
이게 왜 중요하냐면, store가 여러 Slice로 나뉘어져 있을 때 전혀 상관없는 slice에도 접근해서 state값에 접근하거나 action을 dispatch할 수 있다는 이야기다.
예를 들어 1) loginSlice 2) modalSlice 가 있다고 가정하자.
login이 되었을 때 어떤 modal 을 열고 싶다고 했을 때, login과 modal은 완전히 다른 slice임에도 불구하고 login의 thunk 함수에서 thunkAPI를 사용해 modal reducer를 실행할 수 있다.
그 외에도 오류가 발생했을 경우 rejected action type에 넘겨줄 수 있는 rejectWithValue(value, [meta])
도 있다.
const updateUser = createAsyncThunk(
'users/update',
async (userData, { rejectWithValue }) => {
const { id, ...fields } = userData
try {
const response = await userAPI.updateById(id, fields)
return response.data.user
} catch (err) {
// Use `err.response.data` as `action.payload` for a `rejected` action,
// by explicitly returning it using the `rejectWithValue()` utility
return rejectWithValue(err.response.data)
}
}
)
const reducer = createReducer(initialState, (builder) => {
builder.addCase(fetchUserById.rejected, (state, action) => {
console.log(action.payload) // rejectWithValue에서 넘겨준 err.response.data
})
})
리덕스 툴킷에서 타입스크립트를 사용하는 법이 궁금하다면?
출처
- [RTK docs - createAsyncThunk]
해당 글의 모든 소스 코드 출처는 Redux Toolkit 공식 문서입니다.