Open2

React状態管理 - Globalな文脈の何が嬉しいのか

cotaponcotapon

Redux

HeaderとModalがあったとき、Headerの開閉を UIHeaderReduerUI_HEADER::TOGGLE という Action を、Modalの開閉を UIModaleReducerUI_MODAL::TOGGLE があったとします。

Headerが開いてる状態でModalが開こうとしているときどうするか

ModalのActionに UI_HEADER:TOGGLE を発行するのではなく、 UIHeaderReduerUI_MODAL::TOGGLE のActionを受けてHeaderを閉じます

ページ遷移時にHeaderが開いていたら閉じる

同様に、 UIHeaderReduerROUTER::LOCATION_CHANGE を受けて閉じる

つまり

自分の状態は受け身で、自分以外のActionが発行されたら、自分はどう振る舞えばよいのか、他人から命令されるわけではなく自分で判断したほうがよい

メリット

Reduxが持つすべてのstateにアクセスでき、グローバルな変数を持つことによって上記のような振る舞いができる。

デメリット

複雑化していくと影響範囲がわからなくなってくる

https://speakerdeck.com/takefumiyoshii/redux-falseli-dian-wozhen-rifan-ru?slide=32

cotaponcotapon

Recoil

https://blog.uhy.ooo/entry/2020-05-16/recoil-first-impression/
こちらのリンク先の内容にあるように、カスタムフックとの相性はとても良さそうと思います。書かれてあるとおりですが、Recoilをカスタムフックでカプセル化することによって、Recoilのstateを隠蔽させます。そうすることによって直接Recoilのstateを変更することができなくなり、カスタムフックを通してでないとstateが更新できなくなるので、安全に変更できるメリットがあります。

HeaderとModalがあったとき、Headerの開閉状態を useHeader に、Modalの開閉状態を useModal とします。Recoilをカスタムフックにカプセル化する前提で考えると、useHeaderhandleHeaderToggle handleHeaderOpen handleHeaderClose があるとします。同じ用に useModal には、 handleModalToggle handleModalOpen handleModalClose があるとします。HeaderComponent 自身が開いてるか開いてないかは、 useHeaderheaderState を参照するとして、 ModalComponent が開いた時に HeaderComponent は閉じてほしいとしたときは、 ModalComponentopen メソッドに useHeader のカスタムフックをインポートして、 handleHeaderClose を叩いてあげることになります。 Redux と違って、自分がこうしたいから、相手に対してできることを教えてもらって実行する。みたいな感じになります。

useHeader.tsx
const headerAtom = atom({
  key: "HEADER_ATOM",
  default: false
})

export function useHeader() {
  const  [headerState, setHeaderState] = useRecoilState(headerAtom);

  const headerOpen = () => {
    setHeaderState(true);
  }

  const headerClose = () => {
    setHeaderState(false);
  }

  const headerToggle = () => {
    setHeaderState(!stateHeader);
  }

  return {headerState, headerOpen, headerClose, headerToggle} as const;
}
useModal.tsx
const modalAtom = atom({
  key: "MODAL_ATOM",
  default: false
})

export function useModal() {
  const  [modalState, setModalState] = useRecoilState(modalAtom);

  const modalrOpen = () => {
    setModalState(true);
  }

  const modalClose = () => {
    setModalState(false);
  }

  const modalToggle = () => {
    setModalState(!stateModal);
  }

  return {modalState, modalOpen, modalClose, modalToggle} as const;
}
ModalComponent.tsx
export function ModalComponent() {
  const { modalState handleModalToggle} = useModal();
  const { handleCloseHeader } = useHeader();

  const handleToggle = () => {
    handleModalToggle();
    handleCloseHeader();
  }

  return  (
    <>
      <Modal>
        <ModalContent>モーダルやで</ModalContent>
      </Modal>
      <Button onClick={handleToggle}>モーダルのトグル</Button>
    </>
  );
}

useRecoilValueLoadableのサンプル

例としてログインしているユーザーを取得する。atomdefaultにPromiseを渡すことによって Loadable が取得できるようになる。 currentUserState には、'loading' 'hasValue' 'hasError' が取得できる。

import _ from "lodash";
import { atom, useRecoilValueLoadable } from "recoil";

import { CurrentUser } from "~/src/domain/entity/currentUser";
import { AuthUseCase } from "~/src/domain/interface/usecase/AuthUseCase";

const currentUserAtom = _.memoize((useCase: AuthUseCase) =>
  atom<CurrentUser | null | undefined>({
    key: "CURRENT_USER_ATOM",
    default: useCase.getCurrentUser(),
  })
);

export function useAuth(useCase: AuthUseCase) {
  const currentUserLoadable = useRecoilValueLoadable(currentUserAtom(useCase));
  const { state: currentUserState, contents: currentUser } = currentUserLoadable;

  return [currentUserState, currentUser] as const;
}

test

テストコードはこんな感じ。カスタムフックのテストライブラリは @testing-library/react-hooks がデファクトスタンダードっぽいが、<RecoilRoot>がwrapできないので react-recoil-hooks-testing-library を使う。 @testing-library/react-hooks を元に作られてるので、将来テストライブラリが変わってもテストコードはそんなに大きくは変わらなくても良さそうと予想。。。

import { renderRecoilHook } from "react-recoil-hooks-testing-library";

import { CurrentUser } from "~/src/domain/entity/currentUser";
import { AuthUseCase } from "~/src/domain/interface/usecase/AuthUseCase";

import { useAuth } from "../useAuth";

const useCase: AuthUseCase = {
  getCurrentUser: (): Promise<CurrentUser | null> => {
    throw "not implemented";
  },
};

describe("#useAuth", () => {
  test("currentUser", async () => {
    const user = { userId: "test" } as CurrentUser;
    const getCurrentUserSpy = jest
      .spyOn(useCase, "getCurrentUser")
      .mockReturnValue(new Promise((resolve) => resolve(user)));
    const { result, waitForNextUpdate } = renderRecoilHook(() => useAuth(useCase));

    expect(result.current[0]).toEqual("loading");
    await waitForNextUpdate();
    expect(result.current[0]).toEqual("hasValue");
    expect(result.current[1]).toEqual(user);
    expect(getCurrentUserSpy).toHaveBeenCalledTimes(1);
    getCurrentUserSpy.mockClear();
    getCurrentUserSpy.mockReset();
  });
});