Material-UIとstyled componentsで，next.jsのcssをいい感じに管理する (Jest/TypeScript対応版)

この記事は，Next.jsを使った新規WebアプリをMaterial-UI + Styled Componentsでサクッと作りたい人のため，手順書です．

## Next.js 9.3について

Next.jsは，9.3のリリースによって，JAMStack フレームワークとして，劇的に進化しました．

9系ですでに，自動的に静的ファイル化するという最適化の仕組みが導入されていました．9.3ではこの機能をさらに改良して，`getStaticProps`と`getStaticPaths`というAPIを用意し，事前に静的ページ化するSSG = Static Site Generationと，`getServerSideProps` APIを使って，動的にページを生成する，SSR = Server Side Rendering を柔軟に選ぶことが可能になりました．

これの何がうれしいのかというと，エンジニアがページ単位，コンテンツ単位で，プログラム上で，SSG/SSR, さらに必要であれば，CSR = Client Side Rendering を一つのフレームワークの上で簡単に選べることができるようになったということです．

これまで，HugoなどのSSGの仕組みに乗っかっていると，たとえば1万個の商品を扱うECサイトの場合，少しHTMLの構造を変えるだけで，1万個分のHTMLを再生成する必要があり，その計算量は最良のケースであってもリニアに増えていくことになります．これに対して，ExpressやRuby On Railsなどの既存のサーバーサイドWebフレームワークでは，すべてが動的になり，キャッシュなどを挟むことで高速化・スケーラビリティを担保する必要がありました．

このSSG or SSR/CSRが，まったく別のソリューションとして分断されていたところに，Next.jsがやってきて，同じフレームワーク内で，自由にSSG/SSRを選択できるようになったというのが，みなさんの興奮するべきポイントです．たとえば，ブログサイトの場合，ほとんどのアクセスが最新２０件に偏っているのであれば，そこだけSSGを選択し，残りはSSRにすることで，新規投稿時のbuild時間をかなり高速化することができるでしょうし，ECサイトでも売上の８割の占める２割の商品に限って，SSGし，残りはSSRにするということも可能でしょう．Next.jsはこうしたSSG/SSRの区別を柔軟に組み合わせることのできるhybridなアプリケーションフレームワークなのです．

ということで，Next.jsを使うべき理由を言い切りました．SSGにしたい理由は言わずもがなですよね．可用性，セキュリティ，スケーラビリティ，コスト，あらゆる点で，おいしい藻なのですから．

あとは淡々と作業的なチュートリアルを残しておくので，ぜひ手を動かしてみてください．

## 今回利用するパッケージ

- 言語: TypeScript 3.9.7
- Viewフレームワーク: Next.js 9.3 
- UI Components: Material-UI 4.11.0
- CSS フレームワーク: Styled Components 5.1.1
- テストフレームワーク: Jest 26.1.0
- テストライブラリ: React Testing Library 10.4.7

## 作業内容

[公式のTypeScript導入記事](https://nextjs.org/learn/excel/typescript)に利用されているブログのサンプルを使って，

1. TypeScript対応させる
2. Material-UI, Styled Componentsを導入して，シュッとUIを変更できるようにする
3. Jest, React Testing Libraryを導入して，Github ActionsでCIを実行する

までを行います．

## 作業手順

完成コードのリポジトリ: https://github.com/o3c9/nextjs-mui-styled-components-jest-rtl

### 1. プロジェクトのセットアップ

```shell
% mkdir nextjs-with-mui-sc // or whatever name you like
% cd nextjs-with-mui-sc
```

### 2. Next.jsアプリのセットアップ

参考: https://nextjs.org/learn/excel/typescript

```shell
% yarn create next-app nextjs-blog --example "https://github.com/vercel/next-learn-starter/tree/master/basics-final"
```

```shell
% yarn dev
```

で起動．http://localhost:3000 を開いて，

<img width="500" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/225534/e29591ed-b485-119e-528a-ff30fd98c7db.png">

を見ることができれば成功．

### 3. `src`ディレクトリ

Next.js 9.1から `src`ディレクトリ以下に`pages`や`components`を配置できるようになったので，まるっとディレクトリを動かしておく

```shell
% mkdir src; mv components lib pages styles src
```

### 4. prettierの設定 (Optional)

エディタの設定によっては，今後行うTypeScript化のときに不必要に，オリジナルのJsファイルを書き換えてしまうことがあるために，追加．あとから好みの設定に変更してもらってOK.

```js
// .prettierrc
{
  "trailingComma": "none",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true
}
```

### 5. Typescript対応

#### 5-1. Typescriptの導入

```shell
% touch tsconfig.json
```

して，next.js serverをkillしてから，再起動すると，tsconfig.jsonの中身が自動的に埋められる．

さらに，必要なパッケージについてもログに表示されるので，そのままコピペで実行します．

```shell
% yarn add --dev typescript @types/react @types/node
```

さて，ここまで準備が整ったら，ExampleのプロジェクトをTypeScriptに変更します


```shell
% find src -name "*.js" | sed 'p;s/.js$/.tsx/' | xargs -n2 mv
```

#### 5-2. Typeエラーの修正

```shell
% tsc --pretty --noEmit
```

すると，いくつかエラーが出ているので，対応していきます．


いずれも型情報が足りなくてエラーになっているようなので，適宜追加してやります.

```diff
diff --git a/src/components/layout.tsx b/src/components/layout.tsx
index 7c0f470..a79d751 100644
--- a/src/components/layout.tsx
+++ b/src/components/layout.tsx
@@ -6,7 +6,13 @@ import Link from 'next/link'
 const name = '[Your Name]'
 export const siteTitle = 'Next.js Sample Website'

-export default function Layout({ children, home }) {
+export default function Layout({
+  children,
+  home
+}: {
+  children: any
+  home?: any
+}) {
   return (
     <div className={styles.container}>
       <Head>
diff --git a/src/lib/posts.tsx b/src/lib/posts.tsx
index 53d8653..487bedb 100644
--- a/src/lib/posts.tsx
+++ b/src/lib/posts.tsx
@@ -4,12 +4,17 @@ import matter from 'gray-matter'
 import remark from 'remark'
 import html from 'remark-html'

+type MatterData = {
+  title: string
+  date: Date
+}
+
 const postsDirectory = path.join(process.cwd(), 'posts')

@@ -23,7 +28,7 @@ export function getSortedPostsData() {
     // Combine the data with the id
     return {
       id,
-      ...matterResult.data
+      ...(matterResult.data as MatterData)
     }
   })
   // Sort posts by date
```

### 6. Material-UI + Styled componentsの導入

#### 6-1. Styled componentsの導入

```shell
% yarn add -D styled-components @types/styled-components
```

して，`.babelrc.js`を追加します．

```js
// .babelrc.js
module.exports = {
  presets: ['next/babel'],
  plugins: [
    ['styled-components', { ssr: true, displayName: true, preprocess: false }]
  ]
}
```

#### 6-2. Material-UIの導入

```shell
% yarn add -D @material-ui/core @material-ui/styles @material-ui/icons
```

#### 6-3. Next.jsのAppとDocumentをカスタマイズし，Material-UI + Styled Componentsに対応

さて，ここが今回のsetupのミソです. まずは，`_app.tsx`で，`MyApp`を定義し，Material-UI, Styled Componentsそれぞれの`ThemeProvider`でWrapしておきます．

```tsx
// src/pages/_app.tsx
import React, { useEffect } from 'react'

import { ThemeProvider as StyledComponentsThemeProvider } from 'styled-components'
import {
  ThemeProvider as MaterialUIThemeProvider,
  StylesProvider
} from '@material-ui/styles'
import CssBaseline from '@material-ui/core/CssBaseline'

import theme from '../styles/theme'

const MyApp = ({ Component, pageProps }): JSX.Element => {
  // Remove the server-side injected CSS.(https://material-ui.com/guides/server-rendering/)
  useEffect(() => {
    const jssStyles = document.querySelector('#jss-server-side')
    if (jssStyles && jssStyles.parentNode) {
      jssStyles.parentNode.removeChild(jssStyles)
    }
  }, [])

  return (
    <StylesProvider injectFirst>
      <MaterialUIThemeProvider theme={theme}>
        <StyledComponentsThemeProvider theme={theme}>
          <CssBaseline />
          <Component {...pageProps} />
        </StyledComponentsThemeProvider>
      </MaterialUIThemeProvider>
    </StylesProvider>
  )
}

export default MyApp
```

`Document.js(tsx)`というのは，Next.js独自のファイルで，HTMLの`<html>`タグや`<body>`タグの拡張に使われます．このファイルは，ブラウザで実行されることはなく，サーバーサイドでのみ実行されます．つまり，Material-UIやStyled-Componentsで指定したCSSをサーバーサイドレンダリングさせるには，このファイルに設定を用意すればいいということです．Webでよく見つかるものよりは少し丁寧に型をつけています．

```tsx
// src/pages/_documents.tsx
import React from 'react'
import NextDocument, {
  Html,
  Head,
  Main,
  NextScript,
  DocumentContext,
  DocumentInitialProps
} from 'next/document'
import { RenderPageResult } from 'next/dist/next-server/lib/utils'
import { ServerStyleSheet } from 'styled-components'
import { ServerStyleSheets as MaterialServerStyleSheets } from '@material-ui/core'

export default class CustomDocument extends NextDocument {
  static async getInitialProps(
    ctx: DocumentContext
  ): Promise<DocumentInitialProps> {
    const styledComponentsSheet = new ServerStyleSheet()
    const materialUiSheets = new MaterialServerStyleSheets()
    const originalRenderPage = ctx.renderPage

    try {
      ctx.renderPage = (): RenderPageResult | Promise<RenderPageResult> =>
        originalRenderPage({
          enhanceApp: (App) => (
            props
          ): React.ReactElement<{
            sheet: ServerStyleSheet
          }> =>
            styledComponentsSheet.collectStyles(
              materialUiSheets.collect(<App {...props} />)
            )
        })

      const initialProps = await NextDocument.getInitialProps(ctx)
      return {
        ...initialProps,
        styles: [
          <React.Fragment key="styles">
            {initialProps.styles}
            {styledComponentsSheet.getStyleElement()}
            {materialUiSheets.getStyleElement()}
          </React.Fragment>
        ]
      }
    } finally {
      styledComponentsSheet.seal()
    }
  }

  render(): React.ReactElement {
    return (
      <Html lang="ja-JP">
        <Head>
          <link rel="icon" href="/favicon.ico" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}
```

そして，最後にMaterial-UI用のthemeを作成すれば，完了です．

```ts
// src/styles/theme.ts
import { createMuiTheme } from '@material-ui/core'
const theme = createMuiTheme()
export default theme
```

#### 6-4. Styled Componentsを試す．

試しに，Styled Componentsの方法で，既存のcssを書き直してみましょう．

```diff
diff --git a/src/components/layout.tsx b/src/components/layout.tsx
index a79d751..65324c9 100644
--- a/src/components/layout.tsx
+++ b/src/components/layout.tsx
@@ -1,4 +1,5 @@
 import Head from 'next/head'
+import styled from 'styled-components'
 import styles from './layout.module.css'
 import utilStyles from '../styles/utils.module.css'
 import Link from 'next/link'
@@ -6,6 +7,12 @@ import Link from 'next/link'
 const name = '[Your Name]'
 export const siteTitle = 'Next.js Sample Website'

+const Container = styled.div`
+  max-width: 36rem;
+  padding: 0 1rem;
+  margin: 3rem auto 6rem;
+`
+
 export default function Layout({
   children,
   home
@@ -14,7 +21,7 @@ export default function Layout({
   home?: any
 }) {
   return (
-    <div className={styles.container}>
+    <Container>
       <Head>
         <link rel="icon" href="/favicon.ico" />
         <meta
@@ -67,6 +74,6 @@ export default function Layout({
           </Link>
         </div>
       )}
-    </div>
+    </Container>
   )
 }
```

packageを追加しているため，一度serverを止めて，再起動して，レイアウトが崩れていないか確認します．問題なければ，他にも，どんどん書き直してみてください．

Reactアプリケーションに対応したCSSフレームワークはいくつもありますが，基本的に２つ以上のフレームワークを共存させるのは悪夢です．今回は参考例なので，ここで止めますが，実際のプロジェクトなら，徹頭徹尾すべてのcssがstyled componentsとして書き直されるまで，次のステップに進むべきではありません．

#### 6-5. Material-UIのComponentsを使ってみる

今度はMaterial-UIも試しておきましょう．`Paper`componentを使って，ブログ一覧を表示し，Styled componentsを利用して，paddingを調整しておきます．

```diff
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index cabeec7..ea85cac 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -1,9 +1,16 @@
 import Head from 'next/head'
+import styled from 'styled-components'
 import Layout, { siteTitle } from '../components/layout'
 import utilStyles from '../styles/utils.module.css'
 import { getSortedPostsData } from '../lib/posts'
 import Link from 'next/link'
 import Date from '../components/date'
+import Paper from '@material-ui/core/Paper'
+
+const PaperItem = styled(Paper)`
+  margin: 0 0 1.25rem;
+  padding: ${(props) => props.theme.spacing(2)}px;
+`

 export default function Home({ allPostsData }) {
   return (
@@ -22,7 +29,7 @@ export default function Home({ allPostsData }) {
         <h2 className={utilStyles.headingLg}>Blog</h2>
         <ul className={utilStyles.list}>
           {allPostsData.map(({ id, date, title }) => (
-            <li className={utilStyles.listItem} key={id}>
+            <PaperItem component="li" key={id} variant="outlined">
               <Link href="/posts/[id]" as={`/posts/${id}`}>
                 <a>{title}</a>
               </Link>
@@ -30,7 +37,7 @@ export default function Home({ allPostsData }) {
               <small className={utilStyles.lightText}>
                 <Date dateString={date} />
               </small>
-            </li>
+            </PaperItem>
           ))}
         </ul>
       </section>
diff --git a/src/styles/utils.module.css b/src/styles/utils.module.css
index 37f545e..7a52af7 100644
--- a/src/styles/utils.module.css
+++ b/src/styles/utils.module.css
@@ -42,11 +42,6 @@
   padding: 0;
   margin: 0;
 }
-
-.listItem {
-  margin: 0 0 1.25rem;
-}
-
```

<img width="616" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/225534/83004424-9b91-220e-44ae-c7f9f26081cf.png">

このように表示が変われば成功です :tada:

### 7. Jest + React Testing Libraryの導入

```shell
% yarn add -D jest jest-dom ts-jest @types/jest @testing-library/react @testing-library/jest-dom identity-obj-proxy
% yarn add "react-is@>=16.8.0"
```

(`react-is`は，この時点で，peer-dependenciesが未解決であるという警告が出ていたので追加)

#### 7-1. Jestの設定

公式の [with-jest]() exmapleでは `babel-jest`を使っていましたが，test実行時にも型検査が動いてほしいので，ぜひ`ts-jest`を導入しておきましょう．

```js
// jest.config.js
module.exports = {
  roots: ['<rootDir>'],
  moduleFileExtensions: ['js', 'ts', 'tsx', 'json'],
  testPathIgnorePatterns: ['<rootDir>[/\\\\](node_modules|.next)[/\\\\]'],
  transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest'
  },
  globals: {
    'ts-jest': {
      tsConfig: {
        jsx: 'react'
      },
      diagnostics: false
    }
  },
  moduleNameMapper: {
    '\\.(css|less|sass|scss)$': 'identity-obj-proxy'
  },
  setupFilesAfterEnv: ['<rootDir>/src/tests/setupAfterEnv.ts'],
}
```

React Testing Libraryには，`jest-dom`という拡張Matcherが用意されているので，これを使えるように，setupFileを用意します．

```ts
// src/tests/setupAfterEnv.ts
import '@testing-library/jest-dom'
```

テスト環境で，素のままComponentをrenderしてしまうと，Material-UIやStyled Componentsに関する依存が解決されずにエラーになってしまうので，ヘルパー関数を用意します．この関数で，都度，テスト対象のComponentをMaterial-UIとStyled ComponentsのThemeProviderで，wrapしてやるわけです．

```ts
// src/tests/support/renderMUI.tsx
import React from 'react'
import { ThemeProvider as StyledThemeProvider } from 'styled-components'
import {
  ThemeProvider as MaterialThemeProvider,
  StylesProvider
} from '@material-ui/styles'
import { render, RenderResult } from '@testing-library/react'
import theme from '../../styles/theme'

export const renderMUI: (Component: JSX.Element) => RenderResult = (
  Component
) => {
  return render(
    <StylesProvider injectFirst>
      <MaterialThemeProvider theme={theme}>
        <StyledThemeProvider theme={theme}>{Component}</StyledThemeProvider>
      </MaterialThemeProvider>
    </StylesProvider>
  )
}
```

#### 7-2. テストの作成，実行

いよいよ，テストを書きましょう．画面にブログのタイトルである

> When to Use Static Generation v.s. Server-side Rendering

が表示されているか確認します．

```tsx
// src/tests/pages/index.test.tsx
import React from 'react'
import { screen } from '@testing-library/react'

import Home from '../../pages/index'
import { getSortedPostsData } from '../../lib/posts'
import { renderMUI } from '../support/renderMUI'

describe('Home', () => {
  let postsData

  beforeEach(() => {
    postsData = getSortedPostsData()
  })

  it('should render Home', async () => {
    renderMUI(<Home allPostsData={postsData} />)

    expect(
      screen.getByText(
        'When to Use Static Generation v.s. Server-side Rendering'
      )
    ).toBeInTheDocument()
  })
})
```

'React' is not defined`のようなエラーが出る場合，各page, componentの先頭行に，

```ts
import React from "react"
```

を追加してください．

これで，無事通ればOKです．

```shell
$ jest
 PASS  src/tests/pages/index.test.tsx (8.476 s)
  Home
    ✓ should render Home (65 ms)
```

testを実行したら，jestのcacheファイルが大量に生成されているはずなので，gitignoreを編集しておきましょう

```diff
// .gitignore
diff --git a/.gitignore b/.gitignore
index 922d92a..889c12a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@

 # testing
 /coverage
+.jest/cache
```

#### 7-3. Github Actionsの設定

さて，ここまでできれば，あとはGithub Actionsを設定して，PushやMergeのたびにtestを走らせるようにするだけです．

```yml
// .github/workflows/nextjs-ci.yml
name: NextJS CI

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [12.x]

    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
      - name: Run Jest
        uses: stefanoeb/jest-action@1.0.4

```

Github actionsでは，複数のNode.jsのバージョンを使ってテストすることができるわけですが，今回は，`12.x`系のみに絞りました．あなたのつくったリポジトリにpushして，CI上でも問題なくtestがpassできることができれば，環境構築は終わりです．