React でプロジェクトを今、はじめるならみたいな話

by 37108 at 2024/02/02

これからReact を始めたい、Reactに興味があるエンジニアの人や、最近の React 事情に興味がある人に向けての登壇資料のブログ版になります。React がシンプルで、その考え方に従ってコードを書くとそれが綺麗なコードになっていく、そんなReact を利用するのは私は好きです。他にもエコシステムだったり好きな理由は色々あります。

一方で最近はどのフレームワークを使うべきかが悩ましい気もします。フレームワーク側の機能の拡充が特に最近大きく起こり、これからどうしようかなぁという気持ちがあります。また、React を長く書いてきたのですが、本当に今の自分のReact の書き方が正しいのか不安に思うこともあります。

なので、React でプロジェクトを始めるにあたって、自分のNext.js との関わりと、useEffect をベースに良いコードを目指していきます。

プロジェクトを開始するには?

React を利用したプロジェクトの立ち上げにはさまざまなツールがあります。 Webpack や Parcel、Vite といったツールを利用して自分でビルドプロセスを構築して各種ツールを導入してプロジェクトを立ち上げる方法もあれば、Next.js や Remix、Gatsby に代表されるフレームワークを利用して先ほどのプロセスを任せる方法もあります。

今始めるとしたらどうするべきでしょうか。ドキュメントのReact プロジェクトを始める の「フレームワークなしで React を使うことはできますか? 」の部分でも書かれているのでそこから触れていきます。

確かに、React をフレームワークなしで使うことは可能です。既存のページに React を追加する場合はそのようにします。しかし、新しい/アプリやサイトをフルで React を使って構築する場合は、フレームワークを使用することをお勧めします。 理由は次のとおりです。 もし最初にルーティングやデータ取得が必要ない場合でも、後になってそれらのためにライブラリを追加する必要が出てくる可能性が高いでしょう (中略) このページで紹介する React フレームワークは、これらの問題をデフォルトで解決しているため、あなたが余計な作業をする必要はありません。 https://ja.react.dev/learn/start-a-new-react-project#can-i-use-react-without-a-framework

フレームワークを使用することを推奨しています。この部分を引用しましたが、トップページでもフルスタックのフレームワークをお勧めしていたり、同じページでも React をフレームワークとより密接に統合させることで、よりよいアプリ開発を手助けできる機会であると気づいたとの記載があります。

当然フレームワークが要件に合わなくて使えないパターンもありますが、私の経験からもフレームワークで十分なことが多く、テストツールなどのエコシステムともうまく協調しているので、そういったツールの導入までも楽になるので積極的に使うべきだと思っています。私は仕事で Next.js を利用しているので実際触ってみた感想とかを記載していきます。

App Router を選択した理由

App Router を利用したプロジェクトについてお話しします。 今回は アプリをCloudFront + S3 にデプロイするという想定で、つまり静的ホスティングを利用したいというモチベーションで技術選定をしていました。静的出力に対応しているフレームワークとして、Next.js が挙げられ、App Router の Layouts が取り扱いやすそうという部分にも魅力を感じておりそれらが採用の決め手になっていきます。また、プロジェクトのタイミング的にも、App Router が出てからしばらく経ち、知見がある程度見受けられたこと、規模感的にもそこまで難しくないだろうという判断も含まれています。

ルーティングや、ビルドプロセスといった部分で App Router の恩恵を授かりながら、 Server Actions などの新しい機能は極力利用せずに距離をとるようにしました。ルーティング機能としては充分見合っているのですが、React の新機能はベストプラクティスが確立しきっていないことや、そこまでパフォーマンスなどに注力しすぎる必要もなかったりしたので、従来通りのコンポーネントの使い方をしています。

App Router 自体が React Server Components の上に成り立っている技術であることもあり利用はしているのですが、データの取得などはすべて React Client Components で SWR を利用したりして実装しています。 Async Components とか、Server Actions みたいな機能を使いたい気持ちもありますが、Client Components と Server Components での状態の連携だったり、Server Actions のベストプラクティスがわからないということなどもあり利用をしていません。

API との通信の戦略

API との通信には SWR React Client Components 上で利用しています。そのまま SWR が利用できるのですが、API をモックするための MSW が使えない状態になりました。MSW を App Router に組み込むのが、Next.js の実装的に難しいためです。ただし問題の追跡や対応するための方法をできる限りサポートするといった旨の issue も作成されており、また、Next.js では、playwrightとモックを組み合わせて利用可能にする test mode が実験的な機能として組み込まれたので、もしかしら将来的に組み込まれる可能性はありますが、現段階では難しいわけです。 このようにどうしても全てがうまく動くわけではなく、ワークアラウンドを求められることがあることも充分に留意する必要があります。

また、データのやり取りを Server Components で行わないため、今回のプロジェクトで利用する Server Components は Server Actions の利用や、 async Components の利用がないため、状態がなければ副作用も扱わないコンポーネントになります。なので、ロジックのテストをする必要が基本的にないからテストツールの設定が複雑になることもなく、そもそも頑張らなくて良くなります。Storybook も同様にディレクティブが付与されていないただのコンポーネントなので、そのように扱えば良いだけなので設定をせずに表示ができるなど、既存のエコシステムとも比較的馴染みやすい扱い方になっていました。

MSW が使えない問題への対処

MSW を App Router に組み込むのが難しいため、別の方法でモックを準備する必要が出ました。 SWR Hooks は実行の前後でロジックを middleware という機能で挟み込むができます。なのでこれを利用して、middleware で必要なデータを返してしまう、API通信ではなく、SWR Hooks の実行自体をモックする方針にしました。APIをモックしないので、ネットワークトラフィックは発生しないので完全な代替にはなりませんが、それでも良いという場合は採用できます。

まず、SWRConfig Provider で middleware を登録します。use というプロパティに実行したい順で middleware を渡すだけで良いです。 testMiddleware がこの後作成する middleware になります。

export default function Layout({ children }: { children: ReactNode }) {
  return <SWRConfig value={{ use: [testMiddleware] }}>{children}</SWRConfig>;
}

testMiddleware を実装します。SWR Hooks の key とそれに対応する値を保持する MockData というインタフェースに倣ってデータを作成していきます。 そしてそれを、middleware の data 部分で返します。呼び出された SWR Hooks の key が情報としてくるので、それが合致するモックデータを見つけて返すといった実装になります。

もし、エラーを返したいのであれば、 error 部分の実装でエラーを返せば良いですし、 isLoading の値を true にすればローディング状態に変更させることもできます。

interface MockData {
  key: string;
  data: any;
}

const mockData: MockData[] = [
  /** add your data */
];

export const testMiddleware: Middleware = () => {
  return (key): SWRResponse => ({
    data: mockData.find((mock) => mock.key === key)?.data,
    error: undefined,
    mutate: () => Promise.resolve(),
    isValidating: false,
    isLoading: false,
  });
};

このようにどうしても扱いきれなくて他のやり方を探すこともありますが、基本的には App Router をルータとして活用するということはうまくいっています。

フレームワーク選択の結論

あまり多くの機能を利用していないようにも思えますが、Next.js というフレームワークに求める機能と、React に求める機能を分けて考えたときに今回のような使い方も手法として良いのではないかと思っています。

フレームワークに求めているのは、ビルドプロセスやルーティングといった React の外にあるプロセスやツールの導入を簡単にしたいということで、Next.js の場合はそれらに加えて、Layouts の使いやすさで差別化がされて選定しています。 React には、Canary リリースに含まれる機能ではなく、Stable リリースに含まれる今まで通りの作法のReact を今は求めています。 もし、これらを分けて使う判断をしたときに、Next.js のフレームワークとしての機能がルーターとしての機能が充分かどうか、ホスティング先に困らないかなどの部分で判断するのも良いかと思います。Next.js v14.1 で改善はされましたが、History API で状態を扱えるとか、ページ遷移などのイベントを扱えるかなどといった視点で考える要領です。

フレームワークとしての機能を見たときに、 Next.js v14.1 で改善はされましたが、history API での状態が扱えるとか、セルフホスティングがうまく動くか、 ページ遷移の検知などのイベントを Router の events として捌けるかみたいな視点で捉えるのが重要になってきます。 その上で、どうしても App Router の性質上、Server Components が絡んでくるのでそれらとどのくらいの距離感で付き合うのか、どこまで採用するのかといった形で考えを付け加えるのが良いかと思います。

良いコードを書くのはどうして

なぜ良いコードを書こうとするのでしょうか。 複数人が参加するプロジェクトや、既存プロジェクトに参加したことがある人なら感じたことがあるかもしれませんが、コードを書く時間と同じくらいに、コードを読む時間があります。Pull Requests などを通して他の人にコードが正しく動作しているか、実装がどうなっているかを確認をします。場合によっては既存のコードを部分的に、あるいは全てを読む必要が出てきます。良いコードであれば読みやすく、処理が把握しやすいはずです。

つまり良いコードは人のために書くのではないかと私は考えています。 当然コード以外でも適切なコメントを付与したり、レビューのためにもPull Requests の粒度や、不要なリファクタリングなどを混ぜないなど気をつけることはあります。 ただし今回は、React に絞って、そして useEffect に絞って話を進めていきます。 React らしく書けばコードがきっと伝わりやすくなるはずです。

不要な Effect を削除する

React で Effect というと、 useEffect Hook が思い浮かぶと思います。 人のコードを読んでいて理解が進まないのはだいたい、useEffect を多用しているときが多いです。他にも色々ありますが…。

onClick などのようにイベントハンドラの処理はユーザが何をしたら、何が起こるかが、1つの関数が1つの要素に渡されるので直感的に理解できるのが多いのに対して、

useEffect はレンダーによって引き起こされますが、2回目以降は、依存配列に含まれるリアクティブな値は何が契機で変更されるかをコードの全容を知らないと把握できないことがあります。親から渡された状態が実は他の箇所で変更が起きててそれは実はこの Effect にとって不要な実行だったり、そもそもなんで実行されてるかわからないとかが適当に書くと起こりやすく、それが原因かなぁと思っています。

なので、できるだけ不要な useEffect を削除することが読みやすさにつながるかもしれないと思って話を進めていきます。

Effect とは

そもそも、 Effect とはというところから始めていきます。ユーザのクリックなどのイベントのハンドラーや、レンダーによる作用ではなく、コンポーネントのレンダーによって引き起こされる、副作用を指定するのがEffect です。ドキュメントでも色々な言い回しがされていますが、具体例から考えましょう。

例えば、ブラウザ幅の変更を検知して何かをしたい場合、 resize イベントを addEventListener でサブスクリプションします。

useEffect(() => {
  const handler = () => {
    /** do something */
  };

  window.addEventListener("resize", handler);
  () => {
    return window.removeEventListener("resize", handler);
  };
}, []);

このときユーザがブラウザ幅を変更したことに起因することではありますが、onClickonChange のように React であつかえるものではありません。DOM 上でのイベントなので、React の外側で起きていることだからです。 また、そのコンポーネントでブラウザ幅の検知をしたいのは、コンポーネントがマウントされている間のみであり、アンマウントされたのであればUIの振る舞いに影響を与えなくなるので、このサブスクリプションを解除するべきです。 公式ドキュメントの例にはなりますが、コンポーネントでチャットサーバへの接続をする際に、外部 API との同期も同様にReact の外部であり同様に扱うことができます。 「submit ボタンを押下したら、チャットを送信する」であればそれは Effect ではなく、イベントハンドラで扱われるべきロジックですが、チャットサーバへの接続自体はコンポーネントがレンダーされたことによって発生する外部システムとの接続になります。逆にコンポーネントがアンマウントされたのであれば、UIの振る舞いに影響を与えないこのサブスクリプションは解除されるべき、つまりチャットサーバからの接続を解除する必要があります。

useEffect(() => {
  const connection = createConnection();
  connection.connect();
  return () => {
    connection.disconnect();
  };
}, []);

このようなReact の外側にある外部システムとの同期をするために useEffect は基本的に存在します。

Effect は必要ないかもしれない

レンダーによる外部システムとの同期が不要な場合にその useEffect は不要である可能性があります。

扱っているロジックが「ユーザの操作によって引き起こされたかどうか」と「外部システムとの同期のために利用されているか」という観点から考えてみましょう。

つまり、レンダーのために状態のデータを変換する作用を扱うのに、 useEffect を利用したり、ライブラリ側が提供しているイベントハンドラ系の処理を利用しなかったり、そもそもユーザイベントの処理のために useEffect を利用している場合は見直しが必要かもしれません。

諸事情で難しいときは正直なところあります。なのでそのリファクタリングで見通しが良くなるのであれば進めるというのも良いかと思います。

ここからは数個の例示を見ていきます。

状態に基づいて状態を更新しない

一番シンプルな公式からの例ではありますが、こういったことをしているコードはあります。

const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");
const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(firstName + " " + lastName);
}, [firstName, lastName]);

入力欄が2つあり、ファーストネームとラストネームを受け付けて、それを結合してフルネームを何かに使うコードです。そもそも、フルネームは2つの状態から計算可能な値です。外部システムとのやりとりがあるわけでもありません。そしてファーストネームか、ラストネームの入力をした直後に、フルネームが useEffect を経由して状態の更新が起こるので、不要に再レンダーが走ります。

普通にフルネームを定義してあげれば、useState とか useMemo などを利用しない限りは再レンダーが行われると再計算が行われるので、このように定義するだけでことたります。

const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");

const fullName = `${firstName} ${lastName}`;

もし仮にある状態から必要な状態が計算可能だが、重たい処理なので再レンダーごとに計算して欲しくないなどがあれば memo化しましょう。今回のケースをそのまま当てはめるとなんだか微妙になりますが、リアクティブな値の変化にのみ反応して欲しい場合でかつ、Effect でない場合は useMemo が活用できるはずです。

const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");

const fullName = useMemo(() => {
  return calculateFullName(firstName, lastName);
}, [firstName, lastName]);

他にもいろんなケースで useEffect が不要なパターンはありますが、ドキュメントにも載っているので省略します。ジックがレンダーによって外部システムとの同期に使われていないのであれば、それは不要なEffect である可能性があるので今一度よくみてみましょう。

ライブラリ側でイベントを処理できる

SWR で初期データを取得して、React Hook Formにデータを入れる例を考えていきます。

const { data } = useSWR("/wines/reds", fetcher);
const methods = useForm<FormSchema>();

useEffect(() => {
  if (!data) {
    return;
  }
  setValue("winery", data.winery);
  setValue("wine", data.wine);
  setValue("location", data.location);
}, [data, setValue]);

API との通信を終えてその結果をフォームに入れるなので外部システムとの同期の一連の流れではあるのですが、それをライブラリ側でうまく表現できるのに useEffect を利用するのは場合によっては読みづらくなりえます。代替手段がなかったり、こちらの方が可読性が良いとかもあるので良くなり得る方法と捉えてください。

SWR には onSuccess という、APIコールの実行後に呼ばれるコールバック関数があります。

useSWR("/wines/reds", fetcher, {
  onSuccess: (data) => {
    setValue("winery", data.winery);
    setValue("wine", data.wine);
    setValue("location", data.location);
  },
});
const methods = useForm<FormSchema>();

この実装であれば SWR Hooks でAPI通信をして、成功したらフォームに値を入れるという一連の流れがわかりやすくなるかと思います。

または、React Hook Form 側にも値の変化を受け入れるオプションがあるのでそちらに寄せてしまうこともできます。個人的には onSuccess の方が読みやすいかなぁと思ったりします。

const { data } = useSWR("/wines/reds", fetcher);
const methods = useForm<FormSchema>({ values: data });

一歩進むと、React Suspense を利用することで、 SWR Hook で値が必ず取得できているという状態を作り出すこともできます。

// parent component
<Suspense fallback={<Loading />}>
  <Form />
</Suspense>;

// form component
const { data } = useSWR("/wines/reds", fetcher, {
  suspense: true,
});
const methods = useForm<FormSchema>({ defaultValues: data });

API 通信でデータを取得して、初期値として入れる。非常に明快になったのではないでしょうか?

useSyncExternalStore を利用する

外部データストアから値を読み取り、その値をReact に同期するために、 useEffectuseState を利用することがあります。例えばこのようにブラウザ幅の変更を検知するコードがあったとして、useEffect で DOM との接続を行いつつ、 useState に値を保存しています。 これでも良いのですが、 useSyncExternalStore を利用するとどのように外部と同期して、どの値を利用するかをよりわかりやすく書くことができます。

const [size, setSize] = useState(() => window.innerHeight);
useEffect(() => {
  const handler = () => {
    setSize(window.innerWidth);
  };
  window.addEventListener("resize", handler);
  return () => {
    window.removeEventListener("resize", handler);
  };
}, []);

useEffect の中で行われていた、同期をするためのロジックと、同期した上で何をしたいかのロジックが、それぞれ subscribe と getSnapShop という関数に分離できます。そしてこの Hook 自体が状態を返してくれるので、useState が不要になりました。

const subscribe = (cb: VoidFunction) => {
  window.addEventListener("resize", cb);
  return () => {
    window.removeEventListener("resize", cb);
  };
};
const getSnapShop = () => {
  return window.innerWidth;
};

const size = useSyncExternalStore(subscribe, getSnapShop);

useSyncExternalStore を利用することで、1つの Hook だけで外部データをサブスクライブして、その値を React で利用することを表現することができます。もし今後外部への接続をしつつ、値をReact 側に同期したい、つまりサブスクライブしたいような要件があったらぜひ活用を検討してみてください。

useEffect を調整する。良いコードを書く

Effect はレンダーによって引き起こされる副作用を扱うのであって、ユーザ操作のハンドリングや、外部への接続を含まないのであれば useEffect は多分必要ありません。

また、1つの Effect には複数の処理を混ぜ込まずに、1つの独立した同期処理を表現するようにしましょう。 Effect は同期の開始と停止を繰り返すサイクルです。レンダーされる、あるいは利用されるリアクティブな値(コンポーネント本体で宣言された props や state、変数、つまり再レンダー時に変化する可能性がある値)の変化に反応して同期を停止して、再度開始するだけです。1つの useEffect に不用意に複数の処理を混ぜ込むと必要以上にこの処理が発火されてしまうのでそれを防ぎましょう。

最後に依存配列には、依存していない値を入れたり、逆に依存している値を除いたりしないでください。 Effect の依存配列は、自分で選択するものではなく、利用されるリアクティブな値を宣言するものです。

useEffect で定義されるロジックはそれらのリアクティブな値の変化に応じて再度同期処理を行いたくなるような、リアクティブなロジックです。 これが許容できないのであれば、値自体をコンポーネントの外側においてリアクティブでなくしたり、ロジックの一部をイベントハンドラに移動させるなどの対応をしましょう。この辺りをうまく扱えるようにする useEffectEvent みたいな Hook も将来的には安定版で登場するかもしれませんが、今は安定版で利用できないので、頑張って分離するのがベストで、たまには目を瞑りましょう…。

参考文献