지지난 프로젝트였던 '모모'에선 상태관리로 ReduxToolkit을 사용했었다.
기획 단계에선 서버상태관리까지의 필요성을 찾지 못해서 클라이언트에서만 상태관리를 했는데, 중간 단계를 넘어가면서부터 필요성을 체감했다.
export const initialState: IinitialState = {
isLoading: false,
isError: false,
error: null,
post: null,
};
export const **getPostDetail** = createAsyncThunk(
'getPostDetail',
async (postId: string) => {
const response = await getApi<IPost>(`/posts/${postId}`);
return response.data;
},
);
const getPostDetailSlice = createSlice({
name: 'getPostDetailSlice',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(**getPostDetail**.**pending**, (state) => {
state.isLoading = true;
});
builder.addCase(
**getPostDetail**.**fulfilled**,
(state, action: PayloadAction<IPost>) => {
state.isLoading = false;
state.post = action.payload;
},
);
builder.addCase(**getPostDetail**.**rejected**, (state) => {
state.isLoading = false;
});
},
});
export const postSlice = getPostDetailSlice.reducer;
thunk로 비동기 요청(getPostDetail)을 하면 요청의 상태 pending - fulfilled - rejected에 따라 렌더링이 달라야한다. 이를 감지하기 위해서 state마다 isLoading이라는 값을 두었다.
export const DetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const {
isLoading,
post: response,
error,
} = useSelector((state: RootStateType) => state.getPostDetail);
useEffect(() => {
if (!id) return;
const handleAPIError = () => {
alert('API로부터 데이터를 받아올 때 에러가 발생했습니다.');
navigate('/');
};
if (isError) {
handleAPIError();
}
void dispatch(getPostDetail(id));
}, [isError, navigate, dispatch, id]);
if (isLoading)
return (
<>
<StSpinnerWrapper>
<Spinner size={36} />
</StSpinnerWrapper>
</>
);
return ((
<DetailPost
..
/>
)
);
};
요청 상태와 응답 값에 접근하기 위해 selector를 사용한다.
요청 상태인 isLoading과 에러 상태인 error에 따라 조건부로 화면을 렌더링 해야한다.
export const usePostDetail= <T>(postId: string) => {
if(!postId) console.error('postId 정보가 없습니다!');
return useQuery({
queryKey: [`posts/${postId}`, postId],
queryFn: async ()=> {
return await getApi<T>(`/posts/${postId}`)
},
staleTime: Infinity,
})
}
const {data, isLoading, isError} = usePostDetail<IPost>(id!);
const response =data.data
// 이하 동일 --
if (isLoading)
return (
<>
<StSpinnerWrapper>
<Spinner size={36} />
</StSpinnerWrapper>
</>
);
return ((
<DetailPost
..
/>
)
);
말도 안되게 간단해졌다.
useQuery를 래핑한 훅을 따로 두어서 더 간단해졌다 !!
그리고 이 훅의 반환값에 isLoading, isError 등을 자체적으로 가지고 있어서 리듀서랑 비교하여 훨씬 간단하고 “선언적으로” 코드를 작성할 수 있었다.
상황 ) 글쓴이는 Post(하나의 글)를 수정할 수 있다.
글쓴이가 수정 → 서버 상에서의 상태가 변경되어야함 → 변경된 값을 클라이언트에서 접근해야함
UI 흐름 ) 글쓴이가 수정을 하고자 하면 모달이 떠오른다. 글을 생성할 때와 같은 모달이고, 수정이라면 이전값을 보여주고 생성인 경우엔 빈 인풋창인 셈이다
생성도 마찬가지의 흐름이지만 ‘수정’의 상황을 소개해보려고 한다!
수정버튼을 눌러 모달이 뜨고 모달에서 내용을 수정한 수 ‘수정 완료’ 버튼을 누르면 다음 함수가 실행된다.
void dispatch(putPost(data));
export const putPost = createAsyncThunk(
'putPost',
async (body: IputPostBody) => {
const response = await putApiJWT<IPost, FormData>(
`/posts/update`,
createFormData(body),
);
return response.data;
},
);
이 thunk 함수는 요청 성공 시 (fulfiled) 응답 값으로 state를 수정한다.
그럼 아래와 같이 데이터를 불러오던 곳에선 수정된 데이터를 가져온다.
const {
isLoading,
post: response,
error,
} = useSelector((state: RootStateType) => state.getPostDetail);
수정 시엔 ‘update’ 요청으로 서버데이터를 변경하고, 전역데이터도 변경한다.
꺼내 쓰는 state는 전역데이터이다.
서버데이터가 변경시 → 해당 데이터를 쓰는 곳에 다시 패치를 해야한다.
서버, 클라이언트 중복으로 두는 문제가 사라지고 같은 데이터를 바라볼 수 있다.
export const usePutPostDetail = (postId?: string) =>{
const queryClient = useQueryClient();
const { mutate } = useMutation({
mutationFn: async (body: IputPostBody) => await putApiJWT<IPost, FormData>(
`/posts/update`,
createFormData(body),
),
onSuccess: async () =>{
await queryClient.invalidateQueries({
queryKey: [`posts/${postId}`, postId],
});
}
})
return {mutate}
}
useMutation의 반환값 중 mutate를 반환하는 커스텀훅 usePutPostDetail을 만들어 사용한다.
호출할 mutate를 얻기 위해 이 훅엔 postId를 보낸다. → 포스트 수정 시 어떤 포스트인지 알기위함. 그 id값으로 이전 캐싱한 쿼리를 무효화 해야함
mutate를 호출할 때에는 내부의 api 요청의 body에 담을 데이터를 보내야 한다.
이제, 수정버튼을 눌러 모달이 뜨고 모달에서 내용을 수정한 수 ‘수정 완료’ 버튼을 누르면
dispatch() 가 아니라
const { mutate} = usePutPostDetail(post?._id)
...
const handleEdit = () =>{
// ...
mutate(data)
이렇게 사용하면 된다!
우선, tanstack-query를 사용하여 비동기 처리를 했을 때 다음과 같이 로딩중, 에러 처리를 할 수 있다.
export const DetailPage = () => {
const {data, isLoading, isError} = usePostDetail<IPost>(id!);
if (isLoading)
return (
<div> Loading... </div>
);
if (isError)
return (
<div> Error!! </div>
);
return (
<PostDetail ... />
}
알맞게 작동하는 코드지만 조금 개선할 부분을 찾아보면?
Suspense의 역할이 그렇듯, 비동기 처리를 진행중일 때 다른 UI로 대체하여 보여주고 있다가, 진행이 완료되면 알맞은 UI를 보여준다.
v4까지 useQuery 내부 속성이었던 suspense : true 대신 useSuspenseQuery가 생겼다.
suspense , throwOnError , enabled , placeholderData 속성들 제외하고 useQuery랑 사용법이 같다.
suspense를 적용하면 위의 코드를 다음과 같이 수정할 수 있겠다.
우선 useQuery를 매핑했던 usePostDetail 훅을, useSuspense를 매핑한 훅으로 바꿔주면 된다.
useQuery → useSuspenseQuery !
그리고
const Rounter = () =>{
return (
<Suspense fallback = {<Loading />}>
<DetailPage />
</Suspense>
)
export const DetailPage = () => {
const {data} = usePostDetail<IPost>(id!);
return (
<PostDetail ... />
}
쿼리를 호출하는 곳에서 isLoading, isError 등을 반환받아 사용할 필요가 없어졌다.
useSuspenseQuery가 가장 가까운 Suspense로 이 값들을 보냈기 때문이다!!
장점은?
DetailPage안엔 usePostDetail의 응답인 data가 타입에 맞게 존재한다는 걸 보장한다!!
Suspense를 항상 최상단일 필요는 겂고 적절히 나누어 처리해도 좋다
벨로그 나랑 싸우자...
임시글에 내용 덧붙이고 저장 -> 정상
다 덧붙이고 출간 -> 출간된 글 보여줌 -> 내 벨로그 글 목록 -> 글없음 ^^ ..
.. ? ^^