Web/Redux

Redux Toolkit의 createAsyncThunk로 비동기 처리하기

이 글은 개인 공부용으로 Redux Toolkit 공식 문서를 번역한 내용을 담고 있습니다.

  • Redux Toolkit에는 내부적으로 thunk를 내장하고 있어서, 다른 미들웨어를 사용하지 않고도 비동기 처리를 할 수 있다.
  • 물론 Redux Toolkit의 비동기 처리 기능을 사용하지 않고, 컴포넌트 내부의 useEffect()에서 API 호출을 하는 것도 가능하다. 실제로 지금까지 그렇게 해왔다. 다만, Redux Toolkit의 비동기 기능을 사용하면, 컴포넌트 외부에서 비동기 처리를 할 수 있기 때문에 관심사 분리가 가능하다는 장점이 있다.
  • createAsyncThunkcreateSlice를 사용하여 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.payloadfulfilled 액션에 담아 디스패치한다.
      • 프로미스가 거부된 상태라면, rejected 액션을 디스패치하되 rejectedValue(value) 함수의 반환값에 따라 액션에 어떤 값이 넘어올지 결정된다.
        • rejectedValue가 값을 반환하면, action.payloadreject 액션에 담는다.
        • rejectedValue가 없거나 값을 반환하지 않았다면, action.error 값처럼 오류의 직렬화된 버전을 reject 액션에 담는다.
    • 디스패치된 액션이 어떤 액션인지에 상관없이, 항상 최종적으로 디스패치된 액션을 담고 있는 이행된 프로미스를 반환한다.

thunk가 항상 이행된 프로미스를 반환하는 이유

  1. Redux Toolkit은 처리된 오류가 그렇지 않은 경우보다 많다고 생각한다.
  2. 디스패치 결과를 사용하지 않는 경우에도 프로미스가 거부되는 상황을 방지하고자 하기 때문이다.

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 내부에서 두 번째 인자로 받은 thunkAPIsignal을 통해 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