Published on

react query & zustand 로 상태관리 하기

프로젝트에서 사용중인 Redux에 여러 단점 들이 있어서 이를 대체하기 위한 react-query, zustand 를 알아보았다.

(AS-IS) Redux 사용시 문제점

  • API 데이터와 Client Side 데이터 들이 하나의 저장소에 혼재되어 있음.
  • Redux 를 사용함으로써 간단한 API 호출, 데이터 저장시에도 action type, action, saga, reducer, selector 와 같이 구조화를 위한 보일러 플레이트가 비대해짐.
  • API 호출시 데이터 캐시, 사용자 경험 향상등을 위한 로직들을 일일히 구현해주어야 함.

AS-IS 개선을 위한 방안

  • Server Side 데이터 와 Client Side 데이터를 분리하여 관리
    • Client Side: zustand
    • Server Side: react-query
  • Flux 패턴과 같은 구조화는 유지하면서 보일러 플레이트를 많이 줄일 수 있는 라이브러리 사용 (zustand)
    • zustand 사용이유
      • redux와 같은 Flux 패턴을 사용하면서 많은 보일러플레이트 필요 X. 사용방법이 쉬움
      • 동일한 flux 패턴을 사용하는 redux devtool 로 디버깅 가능
      • 번들 사이즈가 작음
      • react와 함께 사용할 수 있는 API를 제공하되, react 종속적이지는 않음. - 따로 provider가 필요없음
        • 내부적으로 useSyncExternalStore 를 사용하여 react의 리렌더링 시스템에 올라갈 수 있도록 해줌
          • useSyncExternalStore는 외부 스토어를 subscribe해서 스냅샷의 변경 여부를 확인하고, 스냅샷이 변경되었을 때 강제로 리렌더링을 유발하기 위해 사용된다. 변경이 감지되면 forceUpdate를 통해 리렌더링이 강제된다.
    • zustand 리렌더링 방지를 위한 useShallow
  • API 호출시 데이터 캐시, 사용자 경험 향상등을 위한 비동기 로직들을 손쉽게 다룰 수 있는 라이브러리 활용 (react-query)

적용

기존 Redux에서 action, saga, reducer, selector 로 나뉘어 있는 로직은 custom hook 으로 구현

아래 프론트엔드 상태관리 실전 편 with React Query & Zustand 발표 자료 참고

  • Business Layer 는 필요하지 않은 경우 만들지 않고, 바로 Component에서 Store Layer를 사용할 수도 있다.

테스트

react query hook test

// createQueryWrapper.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';

export function createQueryWrapper() {
  const testQueryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  });
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
  );

  return Wrapper;
}
// useConsentInfo.test.ts
import { expect } from '@jest/globals';
import { renderHook } from '@testing-library/react-hooks';


import { ConsentAPI } from '@store/api';
import { createQueryWrapper } from '@tests/utility/createQueryWrapper';

import { useConsentInfo } from './useConsentInfo';

...

// guide : https://tanstack.com/query/v3/docs/react/guides/testing
describe('useConsentInfo', () => {
  it('fetch consent info', async () => {
    jest.spyOn(ConsentAPI, 'fetchConsentInfo').mockImplementation(
      //@ts-ignore
      () =>
        Promise.resolve({
          consents: consentList,
          decisionKey: 'testKey',
        })
    );

    const { result, waitFor } = renderHook(() => useConsentInfo({ consentId: ['testId'] }), {
      wrapper: createQueryWrapper(),
    });

    await waitFor(() => !result.current.isConsentInfoLoading);
    expect(result.current.consentInfo?.decisionKey).toBe('testKey');
    expect(result.current.consentInfo?.consents?.length).toBe(consentList.length);
    expect(result.current.consentInfo?.testKey).toBe('test');
  });
});

zustand store hook test

test/__mocks__/zustand.ts 에 아래 파일 추가하면 유닛테스트시 zustand 내부에서 해당 경로에 파일이 있으면, 테스트시 해당 파일에 있는 create함수로 store를 생성한다. (zustand store를 모킹하고 테스트 실행 전 초기화 해주는 코드)

// zustand.ts
// https://docs.pmnd.rs/zustand/guides/testing
import * as zustand from 'zustand';
import { act } from '@testing-library/react';

const { create: actualCreate, createStore: actualCreateStore } = jest.requireActual<typeof zustand>('zustand');

// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>();

const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
  const store = actualCreate(stateCreator);
  const initialState = store.getState();
  storeResetFns.add(() => {
    store.setState(initialState, true);
  });
  return store;
};

// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
  console.log('zustand create mock');

  // to support curried version of create
  return typeof stateCreator === 'function' ? createUncurried(stateCreator) : createUncurried;
}) as typeof zustand.create;

const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
  const store = actualCreateStore(stateCreator);
  const initialState = store.getState();
  storeResetFns.add(() => {
    store.setState(initialState, true);
  });
  return store;
};

// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(stateCreator: zustand.StateCreator<T>) => {
  console.log('zustand createStore mock');

  // to support curried version of createStore
  return typeof stateCreator === 'function' ? createStoreUncurried(stateCreator) : createStoreUncurried;
}) as typeof zustand.createStore;

// reset all stores after each test run
afterEach(() => {
  if (storeResetFns.size) {
    act(() => {
      storeResetFns.forEach((resetFn) => {
        resetFn();
      });
    });
  }
});
// useConsentAgreeInfoStore.test.ts
import { act, renderHook } from '@testing-library/react-hooks';

import { useConsentAgreeInfoStore, initialValue } from './useConsentAgreeInfoStore';

...

describe('useConsentAgreeInfoStore', () => {
  it('useConsentAgreeInfoStore action work', () => {
    const { result } = renderHook(() => useConsentAgreeInfoStore());
    const updateAgreeInfo = {
      [CONSENT_TYPE.TEST_TYPE]: TERM_AGREEMENT_TYPE.AGREE,
    };

    expect(result.current.agreeInfo).toBe(initialValue);
    act(() => result.current.setAgreeInfo(updateAgreeInfo));
    expect(result.current.agreeInfo).toMatchObject(updateAgreeInfo);
    act(() => result.current.resetAgreeInfo());
    expect(result.current.agreeInfo).toBe(initialValue);
  });
});

적용시 참고 할만한 링크

https://tkdodo.eu/blog/practical-react-query

https://github.com/ssi02014/react-query-tutorial

https://www.npmjs.com/package/zustand?ref=nextree.io


그 밖의 참조