이 글은 개인 공부용으로 Redux Toolkit 공식 문서를 번역한 내용을 담고 있습니다.
- Redux Toolkit에는 내부적으로 thunk를 내장하고 있어서, 다른 미들웨어를 사용하지 않고도 비동기 처리를 할 수 있다.
- 물론 Redux Toolkit의 비동기 처리 기능을 사용하지 않고, 컴포넌트 내부의
useEffect()
에서 API 호출을 하는 것도 가능하다. 실제로 지금까지 그렇게 해왔다. 다만, Redux Toolkit의 비동기 기능을 사용하면, 컴포넌트 외부에서 비동기 처리를 할 수 있기 때문에 관심사 분리가 가능하다는 장점이 있다. createAsyncThunk
와createSlice
를 사용하여 Redux Toolkit만으로 비동기 처리를 쉽게 할 수 있으며, redux-saga에서만 사용할 수 있던 기능(이미 호출한 API 요청 취소하기 등)까지 사용할 수 있다.
thunk 만들기
createAsyncThunk
- 액션 타입 문자열, 프로미스를 반환하는 비동기 함수, 추가 옵션 순서대로 인자를 받는 함수다.
- 입력받은 액션 타입 문자열을 기반으로 프로미스 라이프사이클 액션 타입을 생성하고, thunk action creator를 반환한다.
- thunk action creator: 프로미스 콜백을 실행하고 프로미스를 기반으로 라이프사이클 액션을 디스패치한다.
- 리듀서를 생성해주는 기능은 없기 때문에 액션들을 처리할 로직을 직접 작성해야 한다.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
const fetchUserById = createAsyncThunk(
// string action type value: 이 값에 따라 pending, fulfilled, rejected가 붙은 액션 타입이 생성된다.
'users/fetchByIdStatus',
// payloadCreator callback: 비동기 로직의 결과를 포함하고 있는 프로미스를 반환하는 비동기 함수
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId);
return response.data;
},
// 세 번째 파라미터로 추가 옵션을 설정할 수 있다.
// condition(arg, { getState, extra } ): boolean (비동기 로직 실행 전에 취소하거나, 실행 도중에 취소할 수 있다.)
// dispatchConditionRejection: boolean (true면, condition()이 false를 반환할 때 액션 자체를 디스패치하지 않도록 한다.)
// idGenerator(): string (requestId를 만들어준다. 같은 requestId일 경우 요청하지 않는 등의 기능을 사용할 수 있게 된다.)
);
- createAsyncThunk는 thunk action creator를 반환한다.
- 위의 경우를 예로 들면, 다음 세 가지 thunk action creator가 반환된다.
- fetchUserById.pending: 'users/fetchByIdStatus/pending' 액션을 디스패치하는 thunk action creator
- fetchUserById.fulfilled: 'users/fetchByIdStatus/fulfilled' 액션을 디스패치하는 thunk action creator
- fetchUserById.rejected: 'users/fetchByIdStatus/rejected' 액션을 디스패치하는 thunk action creator
- 이 액션들이 디스패치되면, thunk는 아래 과정을 실행한다.
pending
액션을 디스패치한다.payloadCreator
콜백을 호출하고 프로미스가 반환되기를 기다린다.- 프로미스가 반환되면, 프로미스의 상태에 따라 다음 행동을 실행한다.
- 프로미스가 이행된 상태라면,
action.payload
를fulfilled
액션에 담아 디스패치한다. - 프로미스가 거부된 상태라면,
rejected
액션을 디스패치하되rejectedValue(value)
함수의 반환값에 따라 액션에 어떤 값이 넘어올지 결정된다.rejectedValue
가 값을 반환하면,action.payload
를reject
액션에 담는다.rejectedValue
가 없거나 값을 반환하지 않았다면,action.error
값처럼 오류의 직렬화된 버전을reject
액션에 담는다.
- 프로미스가 이행된 상태라면,
- 디스패치된 액션이 어떤 액션인지에 상관없이, 항상 최종적으로 디스패치된 액션을 담고 있는 이행된 프로미스를 반환한다.
thunk가 항상 이행된 프로미스를 반환하는 이유
- Redux Toolkit은 처리된 오류가 그렇지 않은 경우보다 많다고 생각한다.
- 디스패치 결과를 사용하지 않는 경우에도 프로미스가 거부되는 상황을 방지하고자 하기 때문이다.
thunk 사용하기
- thunk를 만들었다면, 다음과 같이 slice를 만들면 된다.
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle' },
reducers: {
// standard reducer logic, with auto-generated action types per reducer
},
extraReducers: (builder) => {
// Add reducers for additional action types here, and handle loading state as needed
builder.addCase(fetchUserById.fulfilled, (state, action) => {
// Add user to the state array
state.entities.push(action.payload);
})
},
});
- 이렇게 만든 thunk와 slice는 다음과 같이 컴포넌트에서 사용할 수 있다.
dispatch(fetchUserById(123));
오류 처리하기
- 앞서 말했듯,
createAsyncThunk
는 결과에 상관없이 무조건 항상 이행된 프로미스를 반환한다. 따라서, 오류 처리는 별도의 방법을 사용해서 진행해야 한다. - 디스패치된 thunk가 반환한 이행된 프로미스는
unwrap
프로퍼티를 가지고 있는데, 이를 사용해서 오류 처리를 할 수 있다.- 이 방식은 액션을 디스패치한 컴포넌트 내부에서 오류를 처리한다.
- 각각의 컴포넌트가 서로 다른 방식으로 오류를 처리할 수 있다는 장점이 있다.
const onClick = async () => {
try {
const originalPromiseResult = await dispatch(fetchUserById(userId)).unwrap();
// handle result here
} catch (rejectedValueOrSerializedError) {
// handle error here
}
}
rejectedValue(value)
함수를 사용해서createAsyncThunk
내부에서 오류 처리를 할 수도 있다.
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);
}
}
);
thunk 취소하기
비동기 처리 전 취소하기
- 앞서 말한
createAsyncThunk
의 세 번째 파라미터(옵션)의condition
속성을 통해 비동기 처리 전 thunk를 취소할 수 있다. condition
속성은 thunk 인자(argument)와{ getState, extra }
형식의 객체를 매개변수로 받는 함수다.condition
속성의 함수가false
를 반환하면 thunk가 취소되며 그렇지 않을 경우 thunk는 그대로 실행된다.
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId);
return response.data;
},
{
condition: (userId, { getState, extra }) => {
const { users } = getState();
const fetchStatus = users.requests[userId];
// fetchStatus가 fulfilled이거나 fetchStatus가 loading이면 실행 취소
if (fetchStatus === 'fulfilled' || fetchStatus === 'loading') {
return false;
}
},
// 만약, thunk가 취소되더라도 rejected 액션이 디스패치되길 원한다면
// 옵션의 dispatchConditionRejection 속성을 true로 설정한다. (기본값은 false)
dispatchConditionRejection: true,
}
);
비동기 실행 중 취소하기
- thunk가 이미 실행되었고, 종료되지 않은 상황에서 취소하고자 한다면
dispatch(fetchUserById(userId))
가 반환하는abort
메소드를 사용하면 된다.
import { fetchUserById } from './slice'
import { useAppDispatch } from './store'
import React from 'react'
function MyComponent(props: { userId: string }) {
const dispatch = useAppDispatch();
React.useEffect(() => {
// Dispatching the thunk returns a promise
const promise = dispatch(fetchUserById(props.userId));
return () => {
// `createAsyncThunk` attaches an `abort()` method to the promise
promise.abort();
}
}, [props.userId]);
}
- 또한, 비동기 작업 자체를 취소하고자 한다면
payloadCreator
내부에서 두 번째 인자로 받은thunkAPI
의signal
을 통해 AbortSignal을 사용할 수 있다.
import { createAsyncThunk } from '@reduxjs/toolkit'
const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId: string, thunkAPI) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
signal: thunkAPI.signal,
});
return await response.json();
}
);
예제
- ID를 입력받아 user를 요청하는 예제(같은 요청이 진행 중일 경우 취소, loading state 포함)
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, { getState, requestId }) => {
const { currentRequestId, loading } = getState().users
if (loading !== 'pending' || requestId !== currentRequestId) {
return
}
const response = await userAPI.fetchById(userId)
return response.data
}
}
const usersSlice = createSlice({
name: 'users',
initialState: {
entities: [],
loading: 'idle',
currentRequestId: undefined,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state, action) => {
if (state.loading === 'idle') {
state.loading = 'pending'
state.currentRequestId = action.meta.requestId
}
})
.addCase(fetchUserById.fulfilled, (state, action) => {
const { requestId } = action.meta
if (
state.loading === 'pending' &&
state.currentRequestId === requestId
) {
state.loading = 'idle'
state.entities.push(action.payload)
state.currentRequestId = undefined
}
})
.addCase(fetchUserById.rejected, (state, action) => {
const { requestId } = action.meta
if (
state.loading === 'pending' &&
state.currentRequestId === requestId
) {
state.loading = 'idle'
state.error = action.error
state.currentRequestId = undefined
}
})
},
})
const UsersComponent = () => {
const { users, loading, error } = useSelector((state) => state.users)
const dispatch = useDispatch()
const fetchOneUser = async (userId) => {
try {
const user = await dispatch(fetchUserById(userId)).unwrap()
showToast('success', `Fetched ${user.name}`)
} catch (err) {
showToast('error', `Fetch failed: ${err.message}`)
}
}
// render UI here
}
References
createAsyncThunk | Redux Toolkit
redux toolkit createAsyncThunk