🗂

React でのデータの取り扱いを振り返ると Next.js の App Router は意外としっくりくるかもしれない

2023/07/19に公開

Next.js の App router が 13.4 から stable になりました
GW 中の発表だったので休みにも関わらずついつい情報を追ってしまった方も多かったのではないでしょうか。僕も GW 中の発表後は App Router の情報収集に勤しんでおり、あれこれ考えていましたが、ドキュメントを読むにつれてなんとなくイメージがついてきました。
あれから2ヶ月ほど経ち、特に今までのデータ周りの取り扱い方を考えることで、割と App Router への進化は自然だったのではないかと考えるようになりました。
ここではあくまで僕の印象ベースではありますがデータ(fetch)の取り扱いについて振り返り、なぜ App Router への進化が自然に感じたのかをメモしておきます。

長いので端的にいうと

  • 元々は上位コンポーネントでデータを取得し、下位コンポーネントに流す形だった
    • SWRなどの登場で流れが逆転し、下位コンポーネントでデータを取得するようになった
    • App Routerはそれを推し進める形の進化に感じる

React でのデータの取り扱い

そういうわけで、React におけるデータの取り扱いについて確認していこうと思います。
しかしながら僕はいわゆる RESTful API を利用しての開発がメインだったため、GraphQL の文脈については残念ながら考慮していませんのでご了承ください 🙏
また、あくまでも僕の経験をベースに話を進行しているため、違う文脈ではそうではなかったということもあると思いますが、こちらもご了承ください。

redux-thunk

Hook のない時代、Redux が React のステート管理としては王道でした(今でも選ばれており、個人的には嫌いではないです)。当時 Redux を利用したときに非同期処理を扱う場所はどこかとなると Redux Thunk や redux-saga あたりがよく聞く名前だった気がします。
どちらにしても Redux の middleware を利用して Redux 上で非同期処理を表現しコンポーネントとつなぐという形になっていました。
昔すぎてあんまり覚えていないですが、2018 年ごろに勉強のために作成していたレポジトリからコードを引っ張ってきました。
この時は react-redux の connect 関数を使うことで Redux と結びつけていました。

import { connect } from "react-redux";
import { SideNav } from "../components/organisms";
import store from "../store";
import { switchCity } from "../store/currentCity";
import { fetchWeather } from "../store/weatherList";
import { AsyncDispatch, IRootState } from "../types";

interface IOwnProps {
  store?: unknown;
}

const mapStateToProps = (state: IRootState, ownProps: IOwnProps) => ({
  cityList: Object.keys(state.weatherList),
});

const mapDispatchToProps = (dispatch: AsyncDispatch) => ({
  async onSubmit(city: string) {
    await dispatch(fetchWeather({ city }));
    dispatch(switchCity({ city }));
  },
  onClick(city: string) {
    dispatch(switchCity({ city }));
  },
});

export const Nav = connect(mapStateToProps, mapDispatchToProps)(SideNav);

勉強のために書いたコードなのであれですが、およそこんな雰囲気だったと思います。
重要なことはコンポーネントに対して connect で store を接続していることです。この SideNav というコンポーネントは俗に言う Presentational Component ということになります。

改めて見てみるとまどろっこしいですね。Hook は偉大です。

Hook

その後 Hook が登場します。React 16.8 ですね 🎮
今調べたら 2020年2月6日に正式リリースだったようですね。Hook の登場では界隈に激震が走りました。Redux 不要論が出始めたりさまざまなメンタルモデルが大きく変わったのがこの時だったと思います。

Hook の登場で今よくみる形で fetch が利用されるようになりました。

const useFetch = () => {
  const [loading, setLoading] = useState(false);
  const fetchHoge = useCallback(async () => {
    setLoading(true);
    const result = await fetch("awesome/endpoint");
    const value = await result.json();
    setLoading(false);
    return value;
  }, []);

  return {
    loading,
    fetchHoge,
  };
};

およそこんな形です。

Container Component

当時は storybook を利用した開発がよく行われていました。atomic design もより声高に叫ばれ State を持つコンポーネントとそうではないコンポーネントを明確に分離する(Container/Presentational)パターンが多かったと思います。
特に fetch で情報をとってくる場合、必要なコンポーネント全部で fetch を呼べばその分リクエストが走ってしまうので、かなり上位のコンポーネントで呼ぶ必要がありました。上位のコンポーネントで取得して下に分配していくイメージの実装だったと思います。
分配しすぎてもバケツリレー(Props drilling)が辛いので、何かしら Store や Context に格納し、子でそれらを引き出す形も多く取られました。
ただ、このように Store などと接続する場合は fetch の文脈などを知る必要があり、Storybook やテストに記述するのがすごく難しくなりました。
そういう側面も Container/Presentational に分ける理由だったと思います。
(実際、やたらと責務を沢山持った Container うまれがちだった気もします。これは扱いやすい Presentational Component を意識したために状態を上へ上へと押し上げていったからだったという気がします)

Next.js

Next.js の v9 がリリースされ、話題になったのも同時期くらいだった印象があります。調べてみると SSG とかが入ったのが 9.3 でこれが 2020年の3月くらいなのでちょうど Hook の正式リリースと同時期だったようですね。
余談ですがそれまでは国内では Vue の勢いが強く、特に Nuxt.js が現在の Next.js のような勢いを持っていたように記憶しています。この時期くらいから Next.js と Nuxt.js の力関係が逆転していったように感じます。

Next.js はpage/にファイルを置くとページとして認識される routing 機能が魅力で、getInitialProps、今だとgetServerSidePropsgetStaticPropsなどを利用して SSR 時やビルド時にデータを取得してきて、アプリケーション側で参照することができました。
これらを利用できる場所はpages/以下で page として利用されるファイルだけです。

  • getServerSideProps
    • getServerSideProps can only be exported from a page. You can’t export it from non-page files.

このパターンでも上位のコンポーネントで fetch し、下位のコンポーネントに流すという流れになっていると思います。
この流れが明確に変わったのは、SWRMSWの登場がきっかけにだったように感じます。

SWR と MSW

SWRも Next.js と同じ Vercel 製のライブラリです。
SWR がどのくらいの時期に話題になったのかは詳細な時期は不明ですが、Next が盛り上がったのちに話題になってきたように思います。
おそらく 2020 年中頃 - 2021 年にかけて認知され始めた印象があり、v1 が出たのは 2021 年 8 月末のようです。
ReactQuery というライブラリ(今はTanStackQuery)との比較記事がよく出されていましたが、僕は SWR しか使ったことがないのでそちらについては詳しくないです。

SWR はデータ取得のための React Hook ライブラリです。
素朴に使うと以下のようなコードになります。

const Hoge = () => {
  const { data, isLoading, error } = useSWR("awesome/path", fetcher);
  if (error) {
    return <div>エラー</div>;
  }
  if (isLoading) {
    return <div>Loading</div>;
  }
  return <div>{data?.message ?? ""}</div>;
};

isLoading や error など、fetch する際に毎回定義するような値ををうまくまとめてくれるので非常に魅力的です。
ですが、画期的だったのはリクエストの重複排除が自動的に行われることです。

上記の例では、toggle countというボタンをクリックすることで、複数の<Count />が表示されます。
<Count />コンポーネントはそれぞれがuseSWRで fetch を呼び出しており、fetch が呼び出されるごとに count は increment され、console に count が表示されます。
もし直接 fetch を書いたとすると、<Count />表示時には<Count />の数だけ fetch がはしり count はコンポーネントの数だけ increment されたでしょう。useSWRは重複を自動で排除してくれるためこの場合ではそうはなりませんでした。

今まではタイミングや回数をコントロールするため、fetch は上位のコンポーネントでおこなう必要がありましたが、そうではなくなりました。
同じデータに依存していたとしても、それぞれのコンポーネントで GET を呼ぶことができるようになったのです。これはコンポーネントの独立性を高め、fetch して得る情報であってもどこからでも参照できるようになったことを意味します。分配のためだけに使われた Store は必要なくなりました。
上位で情報を取得し下に分配するモデルから、必要なデータはそれが必要なコンポーネントが取得する形、できるだけ下の方でデータを取得する形に変わりました。下の方でデータを取得できると理想的には上位のコンポーネントは状態を持つ必要がなくなります。多くの部分では状態がなく、より末端でState を持つ形というようになります。
(アイランドアーキテクチャを思い浮かべると似ているかもしれません。状態を持たない部分が海で、状態を持っている部分が島です。)

Storybook とテストと MSW

Store と繋がっていたり fetch する部分があると Storybook やテストが難しくなる問題がありました。Store については単純に Store のProviderで wrap してしまえば動きますが、fetch 関連をうまくモックするのが難しい問題がありました。
そんな時に話題になったのがMSW(Mock Service Worker)です。国内では 2021 年初めくらいから話題になった印象ですが、MSW を利用することでこの問題は解決しました。

MSW はネットワークレベルでリクエストをインターセプトしてモックします。必要な API のモックを記述しておけばそこにアクセスがあった場合はモックを返すようなことが可能です。
MSW については素晴らしい記事が大量に出ているので詳しくはそちらを参照して欲しいですが、MSW はテストでも Storybook でも動かすことができるので fetch の mock を気にする必要が非常に少なくなりました。

これで末端のコンポーネントから fetch を呼んでも Storybook やテストで困らなくなりました。

Post や Store

SWR は上記の通りリクエストをコントロールできるようになり、得た値を必要な箇所に分配できるようになりました。
では例えば更新するためのリクエストはどうでしょうか?こちらも v2 でuseSWRMutationが入り SWR で上手く扱えるようになりました。

ただし、実際にはデータを扱う際に何かしらの変換を加えたりアプリケーション特有の処理が入るなど Store で管理する方が楽な場合もありますし、バックエンドからの返り値以外の状態も多くあるので、Store が全く不要になるかというとそういうことではないという気がもします。

Suspense

SuspenseReact16.6からありました(React.Lazyを使用してのコード分割が目的)が、React18 でより強化された機能としてリリースされました
Suspenseではfallbackを受け取り、children がサスペンドしている最中はfallbackを表示します。サスペンドしている状態が何かというのは別に詳細な記事が多く上がっているので検索していただきたいですが、例えばデータ取得中でコンポーネントがレンダリングされる準備ができていない状態のようなことを指します。
useSWRには簡単にSuspenseを利用できるオプションがあるのでこちらを用いてどのようになるか簡単に説明します。
(現在では React はまだサスペンスをデータ取得フレームワークである SWR などで使うことを推奨していないようです。この件については上記からリンクが辿れます。)
まず、Suspense を使わないコードは以下のような形でした(再掲です)。

const Hoge = () => {
  const { data, isLoading, error } = useSWR("awesome/path", fetcher);
  if (error) {
    return <div>エラー</div>;
  }
  if (isLoading) {
    return <div>Loading</div>;
  }
  return <div>{data?.message ?? ""}</div>;
};

useSWRisLoadingerrorなどの状態を返し、それを元にコンポーネント内で分岐してコンポーネントを返しています。
Suspenseを使うと以下のようになります。

const Hoge = () => {
  const { data } = useSWR("awesome/path", fetcher, { suspense: true });
  // この時dataは必ず存在します
  return <div>{data.message}</div>;
};

使う側では以下のようにして使います。

<ErrorBoundary>
  <Suspense fallback={<div>Loading...</div>}>
    <Hoge />
  </Suspense>
</ErrorBoundary>

useSWRが読み込み中の場合はSuspenseの fallback が Hoge の代わりに返され、エラーがあった場合は
ErrorBoundaryがキャッチします。そのためHogeはデータがあった場合のみを考えればよく非常にシンプルになりました。
Hogeから状態が追い出され、状態は SuspenseErrorBoundary によって表現されることになりました。

App router

上述で見た流れを振り返ると以下のようになります。

  • 上位のコンポーネントで扱っていたデータの取得はより下層のコンポーネントで扱われるようになった
  • リクエストは重複排除されるようになった
  • Suspense を利用することでデータを扱うコンポーネントから状態が追い出されるようになった
  • ローディングっやエラーの状態は React の機能によって表現されるようになった

App Router が 13.4 で stable になり、ドキュメントを読み込みこんだ印象としては App Router ではこの流れがより推し進めれられたということを感じました。

RSC がベースになったということが1つ大きな驚きではありましたが、上述の流れを考えると地続きの進化を感じます。
Page Router では SSR などでデータを取得するには page の最上位から getServerSideProps などで繋ぐしかありませんでしたが、App router ではコンポーネント単位でデータを取得することができ、さらにそれらは自動で重複が排除されます
また、RSC では状態を保つことができないため、取得された値は必ず中身があるものとして扱われます。app/ 配下の特定の階層に loading.tsxerror.tsx を置くことでその階層の Suspense や ErrorBoundary の fallback を作成することも可能です。(もちろん、必要に応じて自分でSuspenseErrorBoundary を利用することもできます。)
この形は上で見た Suspense の形と一緒です。

確かに App Router には他にも多くの機能があったり、fetch 周りの挙動がややこしかったりといろいろありますが、こうしてみると突拍子もない変化というわけではなく、現状を踏まえた上で先に進めるような変化という様に感じました。
むしろデータ取得などで状態を管理したりデータの取得タイミングで悩んでいた部分をサーバーに持っていったことでより設計がシンプルになるのではないかとも考えています。

というわけで

そういうわけで、ざっくり今までのデータ取得の流れから App router を考えてみました。
もちろん僕自身、App router を利用して本格的な開発をまだしたことがないですし、世の中の事例としてもまだ少ないのではないかと思います。
ただ個人的には App router については割と好意的にみていますし、実際に App router を使った本格的な開発ができる機会を楽しみにしています。

Discussion