🐼

Next.jsに「できるだけ」依存しないReactアプリケーションの構成

2021/01/17に公開

TL;DR

本記事で紹介するのは、Redux や React Router を使った React アプリケーション構築時のベストプラクティスを Next.js に適用した考え方です。

  • Next.js を外部モジュールと考え、Container/Presentation の Container を Adapter 層と見なす考え方
  • next/router などの Next.js の組み込みモジュール、Store、SWR(React Query) は Container(Pages) 層で利用する
  • Storybook でコンポーネントを表示する際、Next.js 等のモックをできるだけ作らない
  • 但し、Template 層以下の next/link や next/image への依存は制御できない

なお本記事では、Next.js の依存層、Pages 層とTemplate 層という言葉は以下のことを指しています。

  • Next.js の依存層: Next.js のモジュールを読み込み、Pages 層にデータを渡すレイヤー
  • Pages 層: Store や fetch でデータを取得して Template 層以下へデータを渡すレイヤー
  • Template 層: Pages 層からデータ、イベントハンドラーを props で受け取り、表示するためのレイヤー

Next.jsへの依存について考え始めたきっかけ

まず、私がNext.jsへの依存について考え始めたきっかけは2つあります。

1つ目は、2019年時点で Next.js 採用の意思決定をする会議で、テックリードから「もし将来 Next.js から別のフレームワークに移行する必要が出てきたらどうしますか」という質問を受けたことでした。

当時は、今ほど Next.js の日本語記事もなく、Next.js が将来別のフレームワークに置き換えられるかもしれないとテックリードは危惧したのだと思います。未来は誰にもわからないため、その懸念はもっともです。そして、未来は誰にもわからないという同じ理由で、採用が決まりました。Next.js が2021年の今ほど人気が出るとは、自分も予想していなかったです。

もう1つは、本業で Next.js に Storybookを導入する過程を「Next.js + TypeScriptにStorybookを導入して遭遇したエラーを全て共有します」という記事に書き起こしたことでした。

Next.js のモジュールをモックする際、GitHub issue には古いバージョンの記述が混在していたりと調査が大変でした。業務で作成したアプリケーションのコンポーネントを Storybook で表示したいだけなのに。

また、記事を読んでくれた同僚から「Storybook のためにモックするなら、そもそも依存しなければいいのでは」とアドバイスを貰ったことも依存関係を考え直すきっかけになりました。

副業で新しい知見を得たので本業のアプリケーションに応用した

そのような問題意識を持っていた時、副業でCreate React App、Redux Toolkit、React Router などで構築された React アプリケーションを触る機会がありました[1]

このアプリケーションでは、Redux や React Router への依存は、各ページのコンポーネントの上位にある Container に閉じるようにするというアーキテクチャが採用されていました。

そこで、React Router を next/router に置き換えれば、本業で使っている Next.js アプリケーションでも同じ考え方が適用できると思いました。

上記のアーキテクチャの制約を満たせば、Storybook で React Router(next/router)のような外部モジュールのためにモックを作成する必要がありません。

さらに、Pages 層の下の Template 層以下で外部モジュールへの依存を極力減らしたコンポーネント群で構成されたアプリケーションは、Next.js から Create React App に移行しても置き換えの労力はそれほど大きくなさそうだと推測しています(もちろん、SSR や SSG、ISR などのサーバーを使った機能は使えないですが)。

Next.js + SWR + Reduxを使ったサンプルコンポーネント

具体的にコードで見ていきます。上記の考え方を知るためには、サンプルコンポーネントをリファクタリングするのが一番良い方法です。

このコンポーネントはユーザーのマイページです。ユーザーはこのマイページで以下のことができます。

  • ユーザーは、登録しているプランの名前を見ることができる
    • プランの詳細は SWR でバックエンドから fetch している
  • ユーザーは、ログインしていなければログインページにリダイレクトされる
    • ログインの有無は Store に保持している
  • ユーザーは、プランを解約できる
    • プランの解約に成功すると、トップページにリダイレクトされる

なお、SWR と Redux を合わせて使う場面はあまりないかもしれませんが、合わせて紹介するためにあえて両方とも利用しています。

// pages/mypage.tsx
import { NextPage } from "next";
import Head from 'next/head'
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useSelector } from 'react-redux'
import useSWR from "swr";

import { Plan } from '~/src/types'

const Page: NextPage = () => {
  // next/router への依存
  const router = useRouter()

  // store への依存
  const user = useSelector(state => state.user)

  // SWR への依存
  const endpoint = `/users/${user.id}/plan`
  const { data: plan } = useSWR<Plan | null>(
    // conditional fetching
    // https://swr.vercel.app/docs/conditional-fetching
    user.loggedIn ? endpoint : null,
    () => fetch(endpoint)
  )

  // 未ログインならログインページに遷移
  useEffect(() => {
    if (user.loggedIn) {
      return
    }

    router.push('/login')
  }, [])

  // Link コンポーネントを使っていない場合、
  // 遷移先を prefetch することで画面遷移を高速化する
  // https://nextjs.org/docs/api-reference/next/router#routerprefetch
  useEffect(() => {
    router.prefetch('/')
    router.prefetch('/login')
  }, [])

  const handleClick = async () => {
    try {
      await fetch(endpoint, { method: 'DELETE' })
      router.push('/')
    } catch (e) {
      console.log(e)
    }
  }

  if (!plan) {
    return <div>loading</div>
  }

  return (
    <>
      {/* next/head への依存 */}
      <Head>
        <title>マイページ</title>
      </Head>

      <h1>マイページ</h1>

      <div>
        <h2>現在のプラン名: {plan.name}</div>
        <p>
          <button type="button" onClick={handleClick}>
            解約する
          </button>
        </p>
      </div>
    </>
  )
}

export default Page

pages/mypage.tsxは next/router、Redux Store、SWR に依存しています。では、リファクタリングしていきましょう。

サンプルコンポーネントをリファクタリングする

リファクタリング後のディレクトリ構成

リファクタリング前はpages/maypage.tsxの1ファイルのみでしたが、リファクタリング後は以下のようなディレクトリ構成になります。

.
├── README.md
├── node_modules
├── package.json
├── pages
│   ├── _app.tsx
│   ├── index.tsx
│   └── mypage.tsx
├── public
└── src
    ├── components
    │   └── pages
    │       └── mypage
    │           ├── MyPageContainer.tsx
    │           ├── index.ts
    │           └── presentations
    │               ├── MyPage.tsx
    │               ├── index.stories.tsx
    │               └── index.ts
    └── types
        ├── Plan.ts
        └── index.ts

Next.jsへの依存をpages/mypage.tsxに閉じ込める

// pages/mypage.tsx
import { NextPage } from "next";
import Head from 'next/head'
import { useRouter } from "next/router";
import React, { useEffect } from "react";

import { MyPage, MyPageProps } from '~/src/components/pages/mypage'

type Props = MyPageProps

const Component: React.FC<Props> = (props) => (
  <>
    <Head>
      <title>マイページ</title>
    </Head>

    <MyPage toTop={props.toTop} toLogin={props.toLogin} />
  </>
)

const Page: NextPage = () => {
  const router = useRouter()

  const toTop = () => {
    router.push('/')
  }
  const toLogin = () => {
    router.push('/login')
  }

  useEffect(() => {
    router.prefetch('/')
    router.prefetch('/login')
  }, [])

  return <Component toTop={toTop} toLogin={toLogin} />
}

export default Page

ここが Next.js への接続層です。上記のように記述することで、nextnext/routernext/headへ依存する箇所をpages/mypage.tsxに限定できました。ただし、next/headについては問題のあるケースがあります。こちらは後述します。

これで、next/routernext/headをそれぞれ React Router、React Helmet に差し替える場合、このコンポーネントを書き換えればいいことになります。Pages 層以下のコンポーネントは影響を受けません。

Router については、useRouteruseHistoryに書き換えればOKです。ただし、Prefetch はできないかもしれません。

この他に、getServerSidePropsgetStaticPropsnext/configを使う場合も、この層に記述します。

なお、Reactコンポーネントの書き方は、@takepepeさんの「経年劣化に耐える ReactComponent の書き方」を参考にしています。

Pages層のMyPageContainerコンポーネントを作る

次に MyPageContainer を作成します。Redux の Container コンポーネントと区別するために、この記事では本コンポーネントをPageContainerと呼びます。

PageContainer では Next.jsへの接続層で作成したデータを受け取り、コンポーネント内部でStore、SWRを利用して、Template 層で使うデータやイベントハンドラーを作成します。

// src/components/pages/mypage/MyPageContainer.tsx
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux'
import useSWR from "swr";

import MyPage from './presentations'

export type Props = {
  toTop: () => void
  toLogin: () => void
}

const MyPageContainer: React.FC<Props> = (props) => {
  const user = useSelector(state => state.user)

  const endpoint = `/users/${user.id}/plan`
  const { data: plan } = useSWR(endpoint, () => fetch(endpoint))

  useEffect(() => {
    if (user.loggedIn) {
      return
    }

    props.toLogin()
  }, [])

  const handleClick = async () => {
    try {
      await fetch(endpoint, { method: 'DELETE' })
      props.toTop()
    } catch (e) {
      console.log(e)
    }
  }

  if (!plan) {
    return <div>loading</div>
  }

  return <MyPage plan={plan} onClick={handleClick} />
}

export default MyPageContainer
// src/components/pages/mypage/index.ts
export { default as MyPage } from './MyPageContainer'
export type { Props as MyPageProps } from './MyPageContainer'

この段階で、元のコンポーネントのロジックを全て切り出せました。

これにより、以下のケースが発生してもこのコンポーネントを変更するだけで済み、上位層である Next.js への接続層や、下位層である Template 層には影響を及ぼしません。

  • ログイン/非ログインの判定を Store から SWR に差し替える
  • Plan の取得を SWR から Store に差し替える
  • SWR を React Query に差し替える

おそらく、データ取得方法を GraphQL(apollo client) に差し替えるときもここを書き換えるだけかなと思います。

次に、表示用のコンポーネントMyPageを作りましょう。

Template層のMyPage.tsxコンポーネントを作る

MyPageコンポーネントは Template 層のコンポーネントです。Template 層はフレームワーク等の外部モジュールに依存しないコードで構成します。

// src/components/pages/mypage/presentations/MyPage.tsx
import React from 'react';

import { Plan } from '~/src/types'

export type Props = {
  plan: Plan
  onClick: () => Promise<void>
}

export const Component: React.FC<Props> = (props) => (
  <>
    <h1>マイページ</h1>

    <div>
      <h2>現在のプラン名: {props.plan.name}</div>
      <p>
        <button onClick={props.onClick}>
          解約する
        </button>
      </p>
    </div>
  </>
)

export default Component

このように記述すると、Storybook で上記のコンポーネントを表示する際、next/router等のモックを作成する必要がなくなりました。

// src/components/pages/mypage/presentations/index.stories.tsx
import React from 'react'
import { Meta, Story } from '@storybook/react'

import { Component as MyPage, Props } from './MyPage'

export default {
  title: 'pages/MyPage',
  argTypes: {
    onClick: { action: 'onClick clicked' }
  }
} as Meta<Props>

const Template: Story<Props> = ({ ...args }) => <MyPage {...args} />

export const Defaut = Template.bind({})
Defaut.args = {
  plan: { name: '月額プラン' }
}

この MyPage コンポーネントを MyPageContainer で呼び出します。

// src/components/pages/mypage/presentations/index.ts
export { default } from './MyPage'

これでリファクタリングが完了しました。

なお、実際にリファクタリングをする場合は、コンポーネントツリーの上から書き換えるのではなく、Template → Pages 層 → Next.js 接続層のように、この記事とは逆の順番でコンポーネントを作成することをオススメします。

さて、これで全てがうまくいきましたと記事を終えたいのですが、そうは問屋が卸しません。

以下では本記事のタイトルを「できるだけ」依存しないとした理由を記述していきます。

適用範囲とその限界

本業で Next.js のアプリケーションを上記の考え方に基づいてリファクタリングしたところ、うまくいくところとそうではないところがあったため、補足していきます。

うまくいったという判断基準は、Storybook で React コンポーネントを表示するときにモックを作成しなくて済むか否かとしています。

以下では、成功パターンと、どうしてもモックを作成せざるを得ないモジュールがあることを紹介していきます。

依存を制御できるケース

今まで見てきたように、以下のモジュールは Next.js 接続層に閉じ込めることができます。

  • next/router
  • next/config(Runtime Configuration)
  • getServerSideProps, getStaticProps, getStaticPaths

Template 層以下ではこれらのモジュールに依存しないため Storybook で表示する際にモックは不要です。

依存を制御できないケース

Next.js への依存を制御できないもの、つまりモックが必要なものは、Next.js 特有の React コンポーネントです。

例えば、Link コンポーネント、Image コンポーネントは Template 層以下のコンポーネントでも利用するため、Storybook でモックが必要です。

.storybook/preview.jsにモックを記述していきましょう。

next/linkのモック(参考: 「Cannot read property 'prefetch' and 'push' of null #16864」

// .storybook/preview.js
import React from "react";
import { RouterContext } from  'next/dist/next-server/lib/router-context';

export const decorators = [
  (Story) => (
    <RouterContext.Provider value={{
      push: () => Promise.resolve(),
      replace: () => Promise.resolve(),
      prefetch: () => Promise.resolve()
    }}>
      <Story />
    </RouterContext.Provider>
  ),
];

next/imageのモック(参考: 「Image component does not work with Storybook #18393」

import * as nextImage from "next/image"

Object.defineProperty(nextImage, "default", {
  configurable: true,
  value: props => {
    const { width, height } = props
    const ratio = (height / width) * 100
    return (
      <div
        style={{
          paddingBottom: `${ratio}%`,
          position: "relative",
        }}
      >
        <img
          style={{
            objectFit: "cover",
            position: "absolute",
            minWidth: "100%",
            minHeight: "100%",
            maxWidth: "100%",
            maxHeight: "100%",
          }}
          {...props}
        />
      </div>
    )
  },
})

まとめると、Template 層以下の React コンポーネントに Props として渡せない場合、Storybook でモックする必要があるということです。

ただし、React は JS であるため、Link コンポーネントや Image コンポーネントを変数に格納して以下のように Props として渡すことが可能です。

この場合<Link /><Image />を Next.js の接続層に閉じ込めることができる一方、このような Link の書き方は一般的ではないため、避けた方がいいでしょう。

import { NextPage } from 'next'
import Link from 'next/link'
import React, { ReactElement } from 'react'

type Props = {
  topLink: (text: string) => ReactElement
}

const Component: React.FC<Props> = (props) => (
  <div>
    {props.topLink('トップページへ')}
  </div>
)

const Page: NextPage = () => {
  const topLink = (text: string) => (
    <Link href="/">
      <a>{text}</a>
    </Link>
  )

  return <Component topLink={topLink} />
}

export default Page

依存を「完全には」制御できないケース

next/head

next/headは case by case です。基本的には、Next.js の接続層で記述します。

ただし、例えば EC サイトにおいて title タグで商品の名前を使いたい場合は、<Head />コンポーネントを Template 層に記述する必要があります。商品の名前は Pages 層で取得するからです。

一方、Next.js の v10.0.5 時点では、Storybook でnext/headをモックする必要はありません。

このため、Next.js への依存が1つ増えることになりますが、上記のような場合では Template 層でnext/headを呼び出すと決めても良いでしょう。

共通コンポーネント「戻るボタン」

「戻るボタン」のようにnext/routerへの依存を前提とした共通コンポーネントを作る場合、next/routerをモックするかは場合によります。

import { useRouter } from 'next/router'
import React from 'react'

type ContainerProps = unknown

type Props = {
  onClick: () => void
}

const Component: React.FC<Props> = (props) => (
  <button onClick={props.onClick}>
    戻る
  </button>
)

const Container: React.FC<ContainerProps> = () => {
  const router = useRouter()
  const handleClick = () => router.back()

  return <Component onClick={handleClick} />
}

export default Container

戻るボタンを使うページの Next.js の接続層で毎回イベントハンドラーを作成してから Template 層へのバケツリレーをするのは面倒に感じるでしょう。

この場合はトレードオフです。各ページでのバケツリレーと、Storybook で next/routerをモックすることを比べた時、後者のコストが低いのであればそちらを選択してもいいと思います。

個人的には、このような戻るボタンを表示する箇所が2ページ以内であればバケツリレーで対応し、3ページ以上で使い回すのであれば router をモックする方を選択します。

handlerを返すCustom Hooksで画面遷移する場合

最後に、アプリケーションをリファクタリングする際に見つけたケースについて記述します。

これは、イベントハンドラーの処理が終わった後、ページ遷移をするパターンです。

const useDoSomething = () => {
  // next/router に依存している
  const router = useRouter()
  const handler = () => {
    // do something
    router.push('/')
  }

  return handler
}

この場合、Custom Hooks とページ遷移の処理を分離することで対応できます。ページ遷移は PageContainer に記述しましょう。

const useDoSomething = () => {
  const router = useRouter()
  const handler = () => {
    // do something
  }

  return handler
}

const PageContainer: React.FC<Props> = (props) => {
  const handler = useDoSomething()
  const handleClick = () => {
    handler()
    props.toTop()
  }

  return <Component onClick={handleClick} />
}

これでグローバルなモジュール(Custom Hooks)のnext/routerへの依存を Pages 層に閉じ込められました。

まとめ

本記事では Next.js というフレームワークに対する依存についての考え方について記述してきました。

本文で紹介した構成は、オニオンアーキテクチャを念頭に置いて、Redux の Container/Presentation の考え方を拡張したものです。

Next.js というフレームワークを外部モジュールとして扱いたかったのですが、記事内で見てきたように依存を完全には制御できませんでした。

それでも、Next.js への依存層、Pages 層、Template 層とレイヤーを分けることで、無闇矢鱈に依存を撒き散らさない React アプリケーションを記述できるかなと思います。

また、Storybook はコンポーネントの表示確認だけではなく、依存関係の健全性チェックにも役立つこともわかりました。おそらく、React Testing Library を使ったコンポーネントテストでもモックの記述が減り、テスタビリティが向上するだろうと思います。

フレームワークとアーキテクチャ

PHP 界で有名な @mpyw さんによる Laravel + クリーンアーキテクチャの素晴らしい記事があります(「5年間 Laravel を使って辿り着いた,全然頑張らない「なんちゃってクリーンアーキテクチャ」という落としどころ」)。

この記事は Laravel というフレームワークを利用しつつ、あらゆるクラスからこのフレームワークに対して依存することを避けるためのアーキテクチャを紹介したものです。記事内の下記の一節が特に印象的です。

フレームワーク変わったらどうするの?
データベースよりは変わる可能性はありますが,それでもビジネスサイドからすれば「品質に問題なく動いているもののアーキテクチャを無理に変えなくていい」とされる場合がほとんどだと思います。このアーキテクチャで品質をしっかり保つことができていれば,そうそうそんな機会はないでしょう。

フレームワークを使いながらもその利用箇所を制限するアーキテクチャを設計することは、プログラムの品質を保ちながらソフトウェアの寿命を伸ばす試みです。

なぜそのようなことをするのか。それは綺麗なコードを書くとエンジニアがハッピーになるという理由もあります。

しかし、ビジネスが軌道に乗った場合、フレームワークの寿命よりビジネスの寿命の方が長いだろうというのがより現実的な理由です。

そして、2年前のテックリードの質問に対する答えは、「将来に備えて、できるだけ Next.js への依存を制御する構成にします」ということにしておきます(笑)。

この記事を読んで下さった方が、フロントエンドのフレームワークとの付き合い方を考えるきっかけになれば幸いです。

(正直なところ、この話は記事の通りにやっていきましょう!という類のものではなく、思考実験として捉えてもらうといいかなと思っています)

Happy Coding 🎉

脚注
  1. Srush というスタートアップのプロダクトです(現在は退職しています)。 ↩︎

Discussion