React状態管理 - Globalな文脈の何が嬉しいのか
Redux
HeaderとModalがあったとき、Headerの開閉を UIHeaderReduer
に UI_HEADER::TOGGLE
という Action
を、Modalの開閉を UIModaleReducer
に UI_MODAL::TOGGLE
があったとします。
Headerが開いてる状態でModalが開こうとしているときどうするか
ModalのActionに UI_HEADER:TOGGLE
を発行するのではなく、 UIHeaderReduer
に UI_MODAL::TOGGLE
のActionを受けてHeaderを閉じます
ページ遷移時にHeaderが開いていたら閉じる
同様に、 UIHeaderReduer
に ROUTER::LOCATION_CHANGE
を受けて閉じる
つまり
自分の状態は受け身で、自分以外のActionが発行されたら、自分はどう振る舞えばよいのか、他人から命令されるわけではなく自分で判断したほうがよい
メリット
Reduxが持つすべてのstateにアクセスでき、グローバルな変数を持つことによって上記のような振る舞いができる。
デメリット
複雑化していくと影響範囲がわからなくなってくる
Recoil
こちらのリンク先の内容にあるように、カスタムフックとの相性はとても良さそうと思います。書かれてあるとおりですが、Recoilをカスタムフックでカプセル化することによって、Recoilのstateを隠蔽させます。そうすることによって直接Recoilのstateを変更することができなくなり、カスタムフックを通してでないとstateが更新できなくなるので、安全に変更できるメリットがあります。
HeaderとModalがあったとき、Headerの開閉状態を useHeader
に、Modalの開閉状態を useModal
とします。Recoilをカスタムフックにカプセル化する前提で考えると、useHeader
は handleHeaderToggle
handleHeaderOpen
handleHeaderClose
があるとします。同じ用に useModal
には、 handleModalToggle
handleModalOpen
handleModalClose
があるとします。HeaderComponent
自身が開いてるか開いてないかは、 useHeader
の headerState
を参照するとして、 ModalComponent
が開いた時に HeaderComponent
は閉じてほしいとしたときは、 ModalComponent
の open
メソッドに useHeader
のカスタムフックをインポートして、 handleHeaderClose
を叩いてあげることになります。 Redux
と違って、自分がこうしたいから、相手に対してできることを教えてもらって実行する。みたいな感じになります。
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;
}
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;
}
export function ModalComponent() {
const { modalState handleModalToggle} = useModal();
const { handleCloseHeader } = useHeader();
const handleToggle = () => {
handleModalToggle();
handleCloseHeader();
}
return (
<>
<Modal>
<ModalContent>モーダルやで</ModalContent>
</Modal>
<Button onClick={handleToggle}>モーダルのトグル</Button>
</>
);
}
useRecoilValueLoadableのサンプル
例としてログインしているユーザーを取得する。atom
の default
に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();
});
});