lamechang-dev

Webフロントエンドエンジニア lamechangのブログ。

【React】【Recoil】ポートフォリオ作成でRecoilを安全かつ綺麗に使おうとしたのでその構成を紹介する

はじめに

昨日、ポートフォリオサイトをvercelでデプロイ・公開しました。

lamechang-dev.vercel.app

レポジトリも公開しています。

github.com

公開の背景としては、以下のようなところが理由になります。

  • 技術的な検証ができるなんでもできるレポジトリが欲しかった
  • せっかくだから公開できるようなものにしたかった

といったところになります🤢

また今回、実装に入る前にアーキテクチャを図に起こしつつで着手しながら進めております。 その時の図を元にどう言った意図があって今の構成にしているかについて、今回はglobalStateにフォーカスして整理してみたいと思います。

レポジトリでのRecoilの運用ルール

  • 1つの単位(ドメイン・UIの状態など)に対して index.tsactions.tsselector.tsをそれぞれ用意する
  • recoilで定義したstatesetStateそのものを直接露出させず、write hookactions.tsread hookselector.tsの中に定義してそれらのカスタムフックのみを利用できるようにする(できる限りライブラリの知識・ルールを隠蔽する)
  • state の定義自体は index.tsの中に集約する
  • stateのkeyは必ず一元管理
  • ドメイン関連のglobalStateは domain/ の下に、 ui関連のglobalStateは ui/ の下に置いていく

実際のコードを以下のサンプルコードで明示していきます。今回実装したものの中で movie と言うドメインがあるので、そちらの構成を見ていきます。

サンプルコード

src/context/model/movies/index.ts

import { atom, selector } from "recoil";
import { GLOBAL_STATE_KEYS } from "src/context/constants";
import { MovieList, Movie } from "../../../domain/movies/model";
import { stateSelectedGenreIds } from "../genres";

export const stateMyFavoriteMovieList = atom<MovieList>({
  key: GLOBAL_STATE_KEYS.DOMAIN.MOVIE.MY_FAVORITE_MOVIE_LIST,
  default: [],
});

export const stateSelectedMovie = atom<Movie | undefined>({
  key: GLOBAL_STATE_KEYS.DOMAIN.MOVIE.SELECTED_MOVIE,
  default: undefined,
});

export const stateSelectedMovieList = selector<MovieList>({
  key: GLOBAL_STATE_KEYS.DOMAIN.MOVIE.SEELCTED_MOVIE_LIST,
  get: ({ get }) => {
    return get(stateMyFavoriteMovieList).filter((movie) => {
      return get(stateSelectedGenreIds).some((id) => {
        return movie.genres?.map((genre) => genre.id).includes(id);
      });
    });
  },
});

ここには純粋な atomselectorの箱だけを置いておくイメージです。特筆する点は特にないとは思います。

src/context/model/movies/selector.ts

import {
  stateMyFavoriteMovieList,
  stateSelectedMovie,
  stateSelectedMovieList,
} from ".";
import { useGlobalValue } from "../../hooks";

export const useStateMyFavoriteMovieList = () => {
  return useGlobalValue(stateMyFavoriteMovieList);
};

export const useStateSelectedMovie = () => {
  return useGlobalValue(stateSelectedMovie);
};

export const useStateSelectedMovieList = () => {
  return useGlobalValue(stateSelectedMovieList);
};

こちらが、globalStateの read hookになっており、単純にatom・selectorを参照し返すものになります。各々のhookは先ほどの index.ts で定義したstateと1対1対応となっており、これらのhooksを view層usecase層 で呼ぶイメージです。

実際の参照する側の記述では

const myFavoriteMovieList = useStateMyFavoriteMovieList();

と言った具合に、recoilの仕様などを意識せずに直感的にhookを通してglobalStateを参照できるようになっています。

ちなみに、useGlobalValue と言うものが登場してますが、こちらは useRecoilValue を単純に代入した関数になっています。recoilに対する直接的な依存を避けるために、間に挟んでいるイメージです。以下のようなhookを用意して利用しています。

■ src/context/hooks/index.ts

import {
  useRecoilCallback,
  useRecoilState,
  useRecoilValue,
  useRecoilValueLoadable,
  useSetRecoilState,
} from "recoil";

export const useGlobalValue = useRecoilValue;

export const useSetGlobalState = useSetRecoilState;

export const useGlobalState = useRecoilState;

export const useGlobalValueLoadable = useRecoilValueLoadable;

export const useGlobalCallback = useRecoilCallback;

src/context/model/movies/actions.ts

import { useRecoilCallback } from "recoil";
import { Movie, MovieList } from "src/domain/movies/model";
import { stateMyFavoriteMovieList, stateSelectedMovie } from ".";

export const useStateMyFavoriteMovieListActions = () => {
  const setStateMyFavoriteMovieList = useRecoilCallback(
    ({ set }) =>
      (movieList: MovieList) => {
        set(stateMyFavoriteMovieList, () => movieList);
      }
  );

  return { setStateMyFavoriteMovieList };
};

export const useStateSelectedMovieActions = () => {
  const setStateSelectedMovie = useRecoilCallback(
    ({ set }) =>
      (movie: Movie) => {
        set(stateSelectedMovie, () => movie);
      }
  );

  const resetStateSelectedMovie = useRecoilCallback(({ set }) => () => {
    set(stateSelectedMovie, () => undefined);
  });

  return { setStateSelectedMovie, resetStateSelectedMovie };
};

こちらが actions.ts の定義になります。いわゆる write hook の置き場所になっており、RecoilのAPIを直接露出させずに更新処理を行えることを狙っています。 また、globalStateに対するwrite処理をこのようにカスタムフックに集約することで、発生しうるwrite処理が網羅的にここで確認できるようになることも大きなメリットであり、テストを書く組織ではテストカバレッジの向上に貢献すると思います。

こういったAPIの隠蔽は、今後Recoil以外のよりナイスな状態管理ライブラリが誕生した際に、そのリプレースの影響範囲を限定できますし、アプリケーションに対するリスクをグッと抑えられるのではないかなと思います。

実際の呼び出し側のコードサンプルも置いておきます。

const { resetStateSelectedMovie } =
    useStateSelectedMovieActions();

const handleClickCloseIconButton = useCallback(() => {
    resetStateSelectedMovie();
  }, [resetStateSelectedMovie]);

その他補足

ドメイン関連のglobalStateは domain/ の下に、 ui関連のglobalStateは ui/ の下に置いていくと言う規約についてですが、これは単純にグルーピングできるものはしていこう、と言う発想からこのようにしました👼🏻が、今後ポートフォリオの機能を増やしていくうちにこの構成が扱いづらいとなったら他の構成に変えていくかもしれませんし、そもそも現段階で domainui の2つのグルーピングが最適解だとは全然思えてないので、作っていきながら調節したいです。

あと、このレポジトリは前述した通り色々なことを実験していくために用意したもので、例えば上述した Recoil以外のよりナイスな状態管理ライブラリが誕生したというシチュエーションも実際に試運転できたらなぁと思ったりしてます。 つまり、この後 Jotai に状態管理を載せ替えてみる、とかも試してみたいなぁなんて思ってたりしますね。Jotaiの方がRecoilより優れている、と言いたい訳ではないです。

jotai.org