Redux Toolkit 튜토리얼 (4)

redux
redux

비동기 로직은 스토어 외부에서 발생해야 합니다. 스토어 안에서 비동기 코드를 작성하기 위해서는 미들웨어를 사용해야 하는데 redux toolkit의 configureStore는 기본적으로 thunk 미들웨어를 자동으로 설정합니다. 리덕스는 비동기 로직을 작성할 때 thunk를 사용하는 것을 권장한다고 합니다.

https://ko.redux.js.org
https://ko.redux.js.org

Thunk

const getRepoDetailsStarted = () => ({
  type: 'repoDetails/fetchStarted',
});
const getRepoDetailsSuccess = (repoDetails) => ({
  type: 'repoDetails/fetchSucceeded',
  payload: repoDetails,
});
const getRepoDetailsFailed = (error) => ({
  type: 'repoDetails/fetchFailed',
  error,
});
const fetchIssuesCount = (org, repo) => async (dispatch) => {
  dispatch(getRepoDetailsStarted());
  try {
    const repoDetails = await getRepoDetails(org, repo);
    dispatch(getRepoDetailsSuccess(repoDetails));
  } catch (err) {
    dispatch(getRepoDetailsFailed(err.toString()));
  }
};

thunk를 직접 작성한다면 코드는 위와 같습니다. 하지만 redux toolkit의 createAsyncThunk는 action type과 creator를 생성하고 액션을 자동으로 디스패치하는 thunk를 생성하여 추상화시킵니다. 또한 비동기 호출을 실행하고 데이터와 함께 프로미스를 반환하는 콜백 함수를 제공합니다.


비동기 thunk 코드 작성을 위해 기존 더미 데이터를 json-server로 이동시킵니다.

npm i json-server

비동기 요청 로딩 상태

로딩 상태는 크게 4가지로 볼 수 있습니다.

  • idle (요청이 시작되지 않음)
  • loading (요청이 진행 중)
  • succeeded (요청 성공)
  • failed (요청 실패)
{
  status: 'idle' | 'loading' | 'succeeded' | 'failed',
  error: string | null
}

위 타입을 토대로 postsSlice의 initialState를 변경합니다. 기존 state는 배열이었지만 객체로 변경하여 로딩 상태와 error를 추가합니다. 상태명은 다른 이름을 사용할 수 있습니다. 또한 {isLoading:true}와 같이 boolean을 사용하여 상태를 추적할 수도 있습니다.

// features/postsSlice.ts
import { PayloadAction, createSlice, nanoid } from '@reduxjs/toolkit';
 
const reactions = {
  eyes: 0,
  heart: 0,
  hooray: 0,
  rocket: 0,
  thumbsUp: 0,
};
 
const initialState: PostState = {
  posts: [],
  status: 'idle',
  error: null,
};
 
const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    postAdded: {
      reducer(state, action: PayloadAction<Post>) {
        state.posts.push(action.payload);
      },
      prepare(title, content, userId) {
        return {
          payload: {
            id: nanoid(),
            date: new Date().toISOString(),
            title,
            content,
            userId,
            reactions,
          },
        };
      },
    },
    postUpdated(
      state,
      action: PayloadAction<Omit<Post, 'userId' | 'date' | 'reactions'>>,
    ) {
      const { content, id, title } = action.payload;
      const existingPost = state.posts.find((post) => post.id === id);
      if (existingPost) {
        existingPost.title = title;
        existingPost.content = content;
      }
    },
    reactionAdded(state, action: PayloadAction<{ id: string; key: string }>) {
      const { id, key } = action.payload;
      const existingPost = state.posts.find((post) => post.id === id);
      if (existingPost) {
        existingPost.reactions[key as keyof typeof reactions] += 1;
      }
    },
  },
});
 
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions;
 
export default postsSlice.reducer;

createAsyncThunk

redux toolkit의 createAsyncThunk API는 start/success/failure 작업을 자동으로 전송하는 thunk를 생성합니다.
createAsyncThunk를 사용하여 json-server에 posts를 요청하겠습니다.

// features/postsSlice.ts
// 코드 생략
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
  const { data } = await axios('http://localhost:4000/posts');
  return data;
});
const postsSlice = createSlice({
  // 코드 생략
});

createAsyncThunk는 두 개의 파라미터를 받습니다.

  1. action type
  2. 데이터가 포함된 프로미스 또는 rejected된 프로미스를 리턴하는 payload creator 콜백 함수

payload creator는 일반적으로 ajax 호출을 수행하며 프로미스를 반환합니다.
PostList컴포넌트에서 fetchPosts 함수를 이용하여 data를 가져오겠습니다. 다른 dispatch 함수와 사용법은 같습니다.

import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from 'hooks';
import { fetchPosts } from 'features/postsSlice';
import PostItem from './PostItem';
 
export default function PostList() {
  const { error, posts, status } = useAppSelector((state) => state.posts);
  const dispatch = useAppDispatch();
 
  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchPosts());
    }
  }, [dispatch, status]);
 
  return (
    <div className="mt-5 flex flex-col gap-y-5">
      {posts.map((post) => <PostItem key={post.id} post={post} />).reverse()}
    </div>
  );
}

dispatch(fetchPosts())를 호출하면 thunk는 먼저 posts/fetchPosts/pending action type을 dispatch 합니다.

프로미스가 완료되면 fetchPosts thunk는 콜백에서 반환한 data 배열을 가져와 action.payload에 포함하여 posts/fetchPosts/fuifilled 액션을 디스패치합니다. dispatch가 두 번 실행되는 이유는 react의 StrictMode 때문입니다.

extraReducers

createSliceaciton creatoraction type을 자동으로 생성합니다. createAsyncThunk로 인해 생성된 action type은 리듀서가 처리하지 못하는데요. 이때 extraReducers를 사용하면 처리할 수 있습니다.

// features/postsSlice.ts
const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    // 코드 생략
  },
  extraReducers(builder) {
    builder
      .addCase(fetchPosts.pending, (state, action) => {
        state.status = 'loading';
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.posts = action.payload;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.error = action.error.message ?? 'posts request failed';
        state.status = 'failed';
      });
  },
});

extraReducers는 builder 매개변수를 받으며 builder 객체는 addCase를 통해 각각의 액션을 처리할 수 있습니다.

usersSlice 또한 initialState 값을 초기화 시킨 후 기존 데이터를 db.json으로 옮깁니다. 그 후 createAsyncThunk를 사용하여 fetchUsers thunk를 추가합니다. usersSlice의 상태는 중복적인 데이터 패칭을 막기 위해 status 대신 isSuccess 하나만 추가하였습니다.

// features/usersSlice.ts
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import axios from 'axios';
 
const initialState: UserState = {
  isSuccess: false,
  users: [],
};
 
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const { data } = await axios('http://localhost:4000/users');
  return data;
});
 
const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {},
  extraReducers(builder) {
    builder.addCase(fetchUsers.fulfilled, (state, action) => {
      state.users = action.payload;
      state.isSuccess = true;
    });
  },
});
 
export default usersSlice.reducer;
// components/posts/PostList.tsx
// 코드 생략
 
useEffect(() => {
  if (!isSuccess) {
    dispatch(fetchUsers());
  }
}, [dispatch, isSuccess]);
 
// 코드 생략

현재는 게시글을 올리면 서버에 추가되지 않고 내부 상태에만 추가됩니다. addNewPost thunk를 생성하여 서버에 POST 요청을 해야 하는데요. body에 id를 추가하지 않은 이유는 jons-server에서 고유 id를 생성하기 때문입니다.

// 코드 생략
 
export const addNewPost = createAsyncThunk(
  'posts/addNewPost',
  async (initialPost: { content: string; title: string; userId: string }) => {
    const { content, title, userId } = initialPost;
    const { data } = await axios.post('http://localhost:4000/posts', {
      title,
      content,
      userId,
      date: new Date().toISOString(),
      reactions,
    } as Omit<Post, 'id'>);
    return data;
  },
);
 
const postsSlice = createSlice({
  name: 'posts',
  initialState,
  reducers: {
    // 코드 생략
  },
  extraReducers(builder) {
    // 코드 생략
    builder.addCase(
      addNewPost.fulfilled,
      (state, action: PayloadAction<Post>) => {
        state.posts.push(action.payload);
      },
    );
  },
});
 
export const { postUpdated, reactionAdded } = postsSlice.actions;
 
export default postsSlice.reducer;

addNewPost thunk를 전송 후, extraReducers를 통해 로딩 상태를 처리하지 않고 useState를 이용해 로딩 상태를 처리했습니다. addRequestStatus가 false 일 때는 버튼의 disabled를 활성화시켜 로딩 중임을 나타냅니다.

// components/posts/PostForm.tsx
// 코드 생략
export default function PostForm({ isEditPage }: { isEditPage?: boolean }) {
  // 코드 생략
  const [addRequestStatus, setAddRequestStatus] = useState(true);
  const dispatch = useAppDispatch();
  const handlePostSave = async (e: React.FormEvent) => {
    e.preventDefault();
    if (title && content) {
      // 코드 생략
      try {
        setAddRequestStatus(false);
        await dispatch(addNewPost({ title, content, userId }));
        navigate('/');
      } finally {
        setAddRequestStatus(true);
      }
    }
  };

참조