React Query + Zustand로 Server State와 Client State를 분리하기

김혜림·2024년 3월 10일
0

react

목록 보기
9/12

React에서 전역상태를 정할 때, 두 가지 분류가 있습니다.
하나는 서버 쪽(API 응답) 데이터, 하나는 클라이언트 쪽 상태입니다.

React Query 와 zustand를 함께 사용하는 이유

  1. 서버 쪽(API 응답) 데이터
    API응답값을 전역상태로 관리하지 않으면 props chaining이 많이 일어나고 유지보수가 어려워집니다. 예시를 들어보겠습니다.

    한 페이지에 List Component가 있고, List Component 하위에는 List Item Component가 있습니다.
    이 경우 API 응답을 useState로 상태 관리한다면, 페이지👉List, List👉List Item 으로 state를 props로 전달해야 합니다. 이러한 Props Chaining은 코드 가독성이 매우 떨어지기 때문에, 우리는 API 응답을 전역 상태로 관리하게 됩니다.
  1. 클라이언트 쪽 상태
    클라이언트 쪽 상태는 API 응답값이 아닌, Front 단의 UI 변경을 위한 상태값입니다. 예시를 들어보겠습니다.

    모달이 열리고 닫히는 상태는 보통 전역 상태로 관리합니다. 모달의 열림/닫힘 여부는 클라이언트 쪽에서만 관리되는 "클라이언트 쪽 상태" 입니다.

React Query는 전역 상태 중 서버 쪽(API 응답) 데이터를 전담하는 라이브러리입니다.

Zustand는 전역 상태 관리 라이브러리인데, Redux나 다른 라이브러리들에 비해 사용하기에 쉽고 가볍습니다. React Query가 이미 서버 쪽 데이터를 전담하므로, Zustand를 사용해서 클라이언트 쪽 상태만을 관리합니다.

예제 리포지토리 링크

예제 리포지토리 바로가기

설치하기

  • axios : npm i axios
  • zustand : npm i zustand
  • react query : npm i @tanstack/react-query
  • query key factory : npm i @lukemorales/query-key-factory
  • react query devtools : npm i @tanstack/react-query-devtools

react query - 프로젝트의 최상단에서 QueryClient, DevTools 세팅하기

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient();

const App = () => {
	return (
		<QueryClientProvider client={queryClient}>
        	<ReactQueryDevtools initialIsOpen={true} buttonPosition="bottom-right" />
        </QueryClientProvider>
    )
}

state를 사용해야 하는 최상단에서 ( 저는 App.jsx에서 ) QueryClientProvider를 설정해줍니다.
그리고 reactQueryDevtools도 추가로 사용합니다.

아래 이미지는 DevTools의 UI입니다.
어떤 Query가 실행되었는지, 해당 Query의 상태가 어떤 상태인지, state의 세부적인 내용들도 devtools를 통해 쉽게 확인할 수 있습니다.

useQuery Hook 작성하기

위에 올려드린 예제에서 API(=service) 코드는 아래와 같이 api폴더에 모아두었습니다.

useQuery, useMutation 코드도 Hook으로 분리하고 싶기 때문에, src 하위에 queries 폴더를 생성하고, 안에 react query hook 코드를 작성했습니다.

import { useMutation } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { TodoApi } from "../api/TodoApi";

export const useTodoListQuery = () => {
  return useQuery({
    queryKey: ['todo','list'],
    queryFn: async () => {
      const res = await TodoApi.getAllTodoList();
      return res;
    },
  });
};

export const useTodoItemQuery = (id) => {
  return useQuery({
    queryKey: [todo,item,{id}],
    queryFn: async () => {
      const res = await TodoApi.getTodoItem(id);
      return res;
    },
    cacheTime: 0,
  });
};

export const useUpdateTodoMutation = (...) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (params) => TodoApi.updateTodoItem(params),
    onSuccess: () => {
      // Mutation 에 성공한 뒤 실행됨
      queryClient.invalidateQueries({
        queryKey: ['todo','list'], // fetchAllTodoList 의 데이터를 무효화 시킴.
      }); ...
    },
    ...
  });
};

Query Hook 사용하기

이제 작성한 query hook을 사용해서 데이터를 불러오겠습니다.

//src/pages/TodoPage.jsx
import TodoListItem from "./components/TodoListItem";
import classes from './TodoPage.module.scss';
import {useTodoListQuery} from "../../query/useTodoQuery";

function TodoPage() {
  const { isLoading, error, data } = useTodoListQuery();

  if (isLoading) return "Loading...";

  if (error) return "An error has occurred: " + error.message;

  return (
      <div className={classes.pagewrapper}>
        <h1>오늘 할 일</h1>
        {
        data.data.map((data, idx)=><TodoListItem key={idx} item={data}/> )
      }</div>
  );
}

export default TodoPage;
// src/pages/todoPage/components/TodoEditModal.jsx
import React, {useEffect, useState} from "react";
import classes from "./TodoEditModal.module.scss";
import {UiStore} from "../../../store/UiStore";
import {useTodoItemQuery, useUpdateTodoMutation} from "../../../query/useTodoQuery";

const TodoEditModal = ({id}) => {
    const modalClose = UiStore(state => state.actions.modalClose);
    const [inputValue, setInputValue] = useState({
        id: id,
        userId: 0,
        title: '',
        completed: false,
    });

    const {isLoading, error, data} = useTodoItemQuery(id);
    const updateData = useUpdateTodoMutation(handleCloseClick);

    /* updateData.mutate : updateData.mutate() => mutationFn을 실행한다.
       updateData.mutateAsync : .mutate와 같으나 promise를 반환한다. */
    async function handleUpdateClick() {
        updateData.mutate({
            id: id,
            userId: data.data.userId,
            title: inputValue.title,
            completed: inputValue.completed,
        });
    }

    function handleCloseClick() {
        modalClose();
    }

    useEffect(() => {
        if (data?.data) {
            setInputValue({
                id: id,
                userId: data.data.userId,
                title: data.data.title,
                completed: data.data.completed,
            })
        }
    }, [data]);

    if (isLoading) return "Loading...";

    if (error) return "An error has occurred: " + error.message;

    return (
        <div className={classes.wrapper}>
            <div className={classes.inputWrapper}>
                <label htmlFor="id">id : </label>
                <input
                    id="id"
                    value={id}
                    onChange={() => {
                    }}
                    disabled
                    className={classes.input}
                />
            </div>
            ...
        </div>
    );
};

export default TodoEditModal;

Query key factory - createQueryKeys : QueryKey 관리하기

위 코드를 보면, queryKey를 직접 배열에 작성해 주었습니다.
['todo','list']
[todo,item,{id}]
그런데, useUpdateTodoMutation을 보면 Mutation 성공 후 queryKey를 이용해서 invalidateQueries를 실행해 주려고 하는데,
useQuery Hook이 많아질 수록 queryKey를 찾아서 일일이 타이핑 해주기 어렵겠죠.

그래서 query key factory 라이브러리도 사용했습니다.

// src/queries/useTodoQuery.js
import { useMutation } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { TodoApi } from "../api/TodoApi";
import { createQueryKeys } from "@lukemorales/query-key-factory";

const todoQueryKeys = createQueryKeys("todo", {
  list: () => [],
  detail: (id) => [{ id }],
  
export const useTodoListQuery = () => {
  return useQuery({
    queryKey: todoQueryKeys.list().queryKey,
    queryFn: async () => {
      const res = await TodoApi.getAllTodoList();
      return res;
    },
  });
};

export const useTodoItemQuery = (id) => {
  return useQuery({
    queryKey: todoQueryKeys.detail(id).queryKey,
    queryFn: async () => {
      const res = await TodoApi.getTodoItem(id);
      return res;
    },
    cacheTime: 0,
  });
};

export const useUpdateTodoMutation = (...) => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (params) => TodoApi.updateTodoItem(params),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: todoQueryKeys.list().queryKey,
      }); ...
    },
    ...
  });
};

todoQueryKeys를 생성했고, 쿼리 키가 필요할 때는 todoQueryKeys를 이용해서 접근합니다.

Query key factory - mergeQueryKeys : 여러 query hook의 query key를 모아서 관리하기

앞서 todo list, todo detail, update todo query를 작성했습니다.
todo API 말고도 user API도 react query hook으로 만들어 보겠습니다.

// src/queries/useUserQuery.js
import { useQuery } from "@tanstack/react-query";
import { UserApi } from "../api/UserApi";
import { createQueryKeys } from "@lukemorales/query-key-factory";

export const userQueryKeys = createQueryKeys("user", {
  detail: (id) => [{ id }],
});

export const useUserItemQuery = (id) => {
  return useQuery({
    queryKey: userQueryKeys.detail(id).queryKey,
    queryFn: async () => {
      const res = await UserApi.getUserInfo(id);
      return res;
    },
  });
};

이렇게 여러개의 useQuery Hook이 생겨나다보니 query key가 js 파일별로 늘어났습니다.
query key를 한 번에 모아두고 싶어지네요.

src/queries/queryKeys.js를 생성하고, mergeQueryKeys를 이용해 한 번의 import로 query key들을 가져오게끔 수정하겠습니다.

// src/queries/queryKeys.js
import {
  createQueryKeys,
  mergeQueryKeys,
} from "@lukemorales/query-key-factory";

export const todoQueryKeys = createQueryKeys("todo", {
  list: () => [],
  detail: (id) => [{ id }],
});

export const userQueryKeys = createQueryKeys("user", {
  detail: (id) => [{ id }],
});

export const queryKeys = mergeQueryKeys(todoQueryKeys, userQueryKeys);

이제 useTodoQuery.js, useUserQuery.js에서는 queryKeys.js의 queryKeys로부터 키 값을 가져옵니다.

// src/queries/useTodoQuery.js
import { useMutation } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { TodoApi } from "../api/TodoApi";
import { queryKeys } from "./queryKeys";

export const useTodoListQuery = () => {
  return useQuery({
    queryKey: queryKeys.todo.list().queryKey,
    queryFn: async () => {
      const res = await TodoApi.getAllTodoList();
      return res;
    },
  });
};

...
// src/queries/useUserQuery.js
import { useQuery } from "@tanstack/react-query";
import { UserApi } from "../api/UserApi";
import { queryKeys } from "./queryKeys";

export const useUserItemQuery = (id) => {
  return useQuery({
    queryKey: queryKeys.user.detail(id).queryKey,
    queryFn: async () => {
      const res = await UserApi.getUserInfo(id);
      return res;
    },
  });
};

zustand로 클라이언트 상태 관리하기 (zustand, zustand/middleware의 devtools)

react query 로 서버 상태 관리를 하도록 구현했습니다.
이제 zustand로 클라이언트 상태관리를 해보겠습니다.
예제에서는 isModalOpen, modalContents 상태를 관리할 것입니다.

먼저 src 하위에 store 폴더를 생성하고, uiStore.js에 아래와 같이 작성했습니다.

import { create } from "zustand";
import { devtools } from "zustand/middleware";

/*
immer를 쓰면 setState((prev) => {...prev, todoList: newList} 처럼 하지 않아도 된다.
*/
import { immer } from "zustand/middleware/immer";

const uiStore = (set, get) => ({
  isModalOpen: false,
  modalContents: <div></div>,

  actions: {
    modalClose: () =>
      set((state) => {
        state.isModalOpen = false;
      }),
    setModalInfoAndOpen: (newModalContents) =>
      set((state) => {
        state.isModalOpen = true;
        state.modalContents = newModalContents;
      }),
  },
});

const store = immer((set, get) => uiStore(set, get));

export const useUIStore = create(devtools(store, { name: "uiStore" }));

1. zustand는 provider와 같은 설정이 따로 필요하지 않습니다.
zustand로 state를 관리할 때는, provider같은 설정은 따로 필요하지 않습니다.

2. zustand/middleware의 devtools 사용하기
zustand/middleware의 devtools를 사용하면 Chrome의 redux devtools 확장 프로그램을 사용해서 zustand 값들을 확인할 수 있습니다. 만약 redux devtools 확장 프로그램을 설치하지 않으셨다면, 아래 링크에서 설치하세요.
아래 이미지처럼 개발자도구에 redux 메뉴가 생겼다면 정상적으로 설치된 것입니다.

redux devtools 확장 프로그램 설치하기 : https://chromewebstore.google.com/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd

profile
개발 일기입니다. :-)

0개의 댓글