Redux Toolkit 튜토리얼 (4)
비동기 로직은 스토어 외부에서 발생해야 합니다. 스토어 안에서 비동기 코드를 작성하기 위해서는 미들웨어를 사용해야 하는데 redux toolkit의 configureStore
는 기본적으로 thunk
미들웨어를 자동으로 설정합니다. 리덕스는 비동기 로직을 작성할 때 thunk
를 사용하는 것을 권장한다고 합니다.
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
는 두 개의 파라미터를 받습니다.
- action type
- 데이터가 포함된 프로미스 또는 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
createSlice
는 aciton creator
와 action 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);
}
}
};