RTK에서 RTK + TanstackQuery로

에구마·2024년 4월 17일

지지난 프로젝트였던 '모모'에선 상태관리로 ReduxToolkit을 사용했었다.
기획 단계에선 서버상태관리까지의 필요성을 찾지 못해서 클라이언트에서만 상태관리를 했는데, 중간 단계를 넘어가면서부터 필요성을 체감했다.

서버 상태 관리가 따로 필요했던 이유

  1. api 요청 하나에 코드가 너무 복잡하고 길다.
    하나의 api 요청에 대해 요청 -> 상태로 저장 하려면 thunk, reducer, actions 등을 구현해야한다. 거의 50줄이 된다 ..
  2. 일관성 무시
    "서버" 상태를 클라이언트에서 관리한다? 부터 모순이 있고, 네트워크 등의 이유로 서버 상태와 클라이언트에서 저장한 상태가 일치하지 않을 수 있다.

TanstackQuery로 전환해보자

기존 코드의 흐름

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에 따라 조건부로 화면을 렌더링 해야한다.

TanstackQuery로 전환해볼께 얍🪄

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 등을 자체적으로 가지고 있어서 리듀서랑 비교하여 훨씬 간단하고 “선언적으로” 코드를 작성할 수 있었다.

mutation으로 상태를 변경해보자

상황 ) 글쓴이는 Post(하나의 글)를 수정할 수 있다.

글쓴이가 수정 → 서버 상에서의 상태가 변경되어야함 → 변경된 값을 클라이언트에서 접근해야함

UI 흐름 ) 글쓴이가 수정을 하고자 하면 모달이 떠오른다. 글을 생성할 때와 같은 모달이고, 수정이라면 이전값을 보여주고 생성인 경우엔 빈 인풋창인 셈이다

생성도 마찬가지의 흐름이지만 ‘수정’의 상황을 소개해보려고 한다!

기존 RTK

수정버튼을 눌러 모달이 뜨고 모달에서 내용을 수정한 수 ‘수정 완료’ 버튼을 누르면 다음 함수가 실행된다.

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는 전역데이터이다.

TanstackQuery로 얍🪄

서버데이터가 변경시 → 해당 데이터를 쓰는 곳에 다시 패치를 해야한다.

서버, 클라이언트 중복으로 두는 문제가 사라지고 같은 데이터를 바라볼 수 있다.

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 ... />
}

알맞게 작동하는 코드지만 조금 개선할 부분을 찾아보면?

  1. 코드가 길다. 매 요청마다 반복작업을 해주어야 한다..
  2. .

useSuspenseQuery로 로딩 중 처리하기

Suspense의 역할이 그렇듯, 비동기 처리를 진행중일 때 다른 UI로 대체하여 보여주고 있다가, 진행이 완료되면 알맞은 UI를 보여준다.

v4까지 useQuery 내부 속성이었던 suspense : true 대신 useSuspenseQuery가 생겼다.

suspense , throwOnError , enabled , placeholderData 속성들 제외하고 useQuery랑 사용법이 같다.

suspense를 적용하면 위의 코드를 다음과 같이 수정할 수 있겠다.

우선 useQuery를 매핑했던 usePostDetail 훅을, useSuspense를 매핑한 훅으로 바꿔주면 된다.

useQueryuseSuspenseQuery !

그리고

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를 항상 최상단일 필요는 겂고 적절히 나누어 처리해도 좋다






벨로그 나랑 싸우자...
임시글에 내용 덧붙이고 저장 -> 정상
다 덧붙이고 출간 -> 출간된 글 보여줌 -> 내 벨로그 글 목록 -> 글없음 ^^ ..
.. ? ^^

profile
코딩하는 고구마 🍠 Life begins at the end of your comfort zone

0개의 댓글