【完全版】React / Next.js で Material-UI を使う (w/ TypeScript)


ReactやVue、Angularが普及した現代のフロントエンド開発において、NextやNuxtのようなフレームワーク（パッケージ）は欠かせないと言えるでしょう。単にSSRやSSGだけでなく、さまざまな最適化を任せることができます。一方で、

- ボタンやナビゲーションバーなどをスクラッチで実装することなく、既存のコンポーネントを使用したい。
- スタイルをコンポーネントファイルごとに管理したい。

などの動機から、スタイルを管理するフレームワークを使用するディベロッパーも少なくありません。そこで、本稿ではReact / Next.jsにフォーカスし、Material-UIを使ってスタイル管理を行うことを目指します。もちろん、実装にはTypeScriptを使用します。

## 目次

## 0. 環境

- macOS Big Sur 11.5.2
- MacBook Pro (13-inch, M1, 2020)
- Node.js v16.9.1
- WebStorm 2021.1

## 1. 技術スタックの概説

#### 1.1. React

https://ja.reactjs.org/

Reactは主にFacebook社が開発しているUI構築フレームワークで、コンポーネントの管理や宣言的な記述を可能にします。同様のものにVueやAngularがあります。[Google Trends](https://trends.google.co.jp/trends/explore?cat=13&date=today%205-y,today%205-y,today%205-y&geo=,,&q=%2Fm%2F012l1vxv,%2Fg%2F11c6w0ddw9,%2Fg%2F11c0vmgx5d#TIMESERIES)によると、2020年2月16日以降ReactはVueとAngularより多く検索されています。また、地域別に見ると、アメリカやイギリス、カナダ、ロシアで最も多く検索されています。

#### 1.2. Next.js

https://nextjs.org/

Next.jsは主にVercel社が開発している、Reactをエンパワーするフレームワークです。サーバーサイドレンダリング（SSR）や静的サイトジェネレーション（SSG）はもちろん、画像の最適化やバンドル、SEOなどさまざまなことを勝手に行ってくれます。

#### 1.3. TypeScript

https://www.typescriptlang.org/

TypeScriptは主にMicrosoft社が開発している、ディベロッパーエクスペリエンスを改善するためのJavaScriptのスーパーセットです。JavaScriptに「型」を付与することで、本来は実行するまで分からないエラーをコンパイル時に（ほとんどはタイプした瞬間に）検出するようにします。モダンなフロントエンド開発にはほとんど欠かせないと言って良いでしょう。

#### 1.4. Material-UI

https://material-ui.com/

Material-UIはコミュニティが開発している、スタイリングのためのフレームワークです。大きく分けて3つのタスクを追います。

1. Material DesignベースのReactコンポーネントを提供します。BootstrapのようなCSSフレームワークと同じ役割と考えて差し支えないと思います。
1. コンポーネントのスタイリングを行います。styled-componentsやCSS Modules、Tailwind CSSとおおよそ同じような役割と考えて差し支えないと思います。
1. 全体のテーマを管理します。例えば、ダークモードへのスイッチを簡単にします。

Material-UIに類するフレームワークのどれを使うべきかは、それぞれ一長一短であり、半ば宗教的な議論に終着してしまいがちですが、Material-UIを使うメリットとして

- ゼロから実装する必要はなく、実装の手間が省ける。
- コンポーネント横断的なスタイル（色など）を管理しやすい。
- コンポーネントファイル（.jsxや.tsxファイル）内では、コンテンツとスタイルを分離できる。

などが挙げられます。

#### 1.5. Vercel

https://vercel.com/

VercelはVercel社が提供する、Next.jsアプリのデプロイ先の一つです。SSGやCDNへの配置などを一手に引き受けてくれます。デプロイだけでなく、プルリクエストに紐づいたプレビューなどを驚くほど簡単に実現できます。個人開発用には無料で使えます。（ただし、組織ではなく個人のリポジトリに紐づける必要があります。）今回はデプロイについての説明は省略しますが、Next.jsアプリ開発で使いたい技術スタックの一つです。

## 2. 実装例

#### 2.1. Next.jsプロジェクトを立ち上げる

Node.jsがインストールされているかを確認します。

```terminal:zsh
% node -v
v16.9.1
```

TypeScriptをサポートしたnextプロジェクトを立ち上げます。npmであれば

```terminal:zsh
% npx create-next-app --ts
```

Yarnであれば

```terminal:zsh
% yarn create next-app --typescript
```

でインストールできます。（以下、簡単のためnpmのコマンドのみを記載します。）プロジェクト名など必要事項を適宜記入してください。確認のため、アプリを立ち上げてみましょう。

```terminal:zsh
% cd path/to/project
% npm run dev
```

ブラウザで以下のリンクを立ち上げてみましょう。

http://localhost:3000/

開発用サーバは ```Ctrl+C``` で閉じられます。

ついでにですが、```./src``` ディレクトリを作成し、```./pages``` を ```./src``` 配下に移動させておきましょう。

#### 2.2. Material-UIをインストールする

###### 2.2.1. npmでインストールする

npmからMaterial-UIをインストールしましょう。Material-UIは主なものに限ると3つのパッケージを提供しています。

- ```@material-ui/core```
    - 最も一般的なパッケージです。各種コンポーネントやスタイリングのための関数などが提供されています。
- ```@material-ui/icons```
    - Material Designのアイコンを使用できます。[こちら](https://material-ui.com/components/material-icons/)から使用できるアイコンを検索できます。
- ```@material-ui/lab```
    - ```core``` に移動する前のアルファ版的なコンポーネントが提供されています。

必要となるパッケージを選んでください。本項ではすべてインストールしようと思います。

```terminal:zsh
% npm i -S @material-ui/core @material-ui/icons @material-ui/lab
```

```./package.json``` を確認すると、```"dependencies"``` に追加されていることが分かります。

#### 2.3. SSRに対応させる

このままだとスタイル定義のタイミングにずれが生じ、```Warning: Prop `className` did not match.``` のような注意がなされます。これを解消するために、nextアプリにおける2つの特別なファイル、すなわち、```./src/pages/_app.tsx``` と ```./src/pages/_document.tsx``` を編集します。これらのファイルの概説は以下の通りです。

- ```_app.tsx```
    - 各ページに共通する部分を記述するためのものです。
- ```_document.tsx```
    - ```<html>``` や ```<body>``` をカスタマイズするためのものです。

これらを以下のように編集してください。

```tsx:./src/pages/_app.tsx
import React, { useEffect } from 'react'
import type { AppProps } from 'next/app'

export default function MyApp({ Component, pageProps }: AppProps) {
  useEffect(() => {
    const jssStyles = document.querySelector('#jss-server-side')
    jssStyles?.parentElement?.removeChild(jssStyles)
  }, [])

  return (
      <Component {...pageProps} />
  )
}
```

```tsx:./src/pages/_document.tsx
import React from 'react'
import Document, { Head, Html, Main, NextScript } from 'next/document'
import { ServerStyleSheets } from '@material-ui/core/styles'

class MyDocument extends Document {

    render() {
        return (
            <Html>
                <Head>
                </Head>
                <body>
                <Main/>
                <NextScript/>
                </body>
            </Html>
        )
    }
}

MyDocument.getInitialProps = async (ctx) => {
    const sheets = new ServerStyleSheets()
    const originalRenderPage = ctx.renderPage

    ctx.renderPage = () =>
        originalRenderPage({
            enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
        })

    const initialProps = await Document.getInitialProps(ctx)

    return {
        ...initialProps,
        styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()],
    }
}

export default MyDocument
```

#### 2.4. テスト

適当にボタンを配置して、Material-UIが適切に使えるかどうかをテストしてみましょう。その前にまず、今回はCSS Modulesを使用しないので、デフォルトの ```./styles``` ディレクトリを削除します。次に  ```./src/pages/index.tsx``` を次のように書き換えます。

```tsx:./src/pages/index.tsx
import React from 'react'
import Button from '@material-ui/core/Button'

export default function Home() {
    return (
        <Button variant="contained">Success!</Button>
    )
}
```

単にSuccess!という表示のボタンを配置しました。最後に、これを確認するために ```npm run dev``` コマンドで開発用サーバを起動し、ブラウザで http://localhost:3000 にアクセスします。その結果、ボタンが表示されるとOKです。念のため、開発者用ツールからコンソールを確認し、Warningが出ていないこともチェックしておいてください。


## 3. 運用例

ここまでで、Next.js上でMaterial-UIを使用する準備ができました。本章では、実際にコーディングする際に、どのようにMaterial-UIを使うべきかの一例を示します。

#### 3.1. React Componentについて

まずいきなりですが、コンポーネントファイルの一例を見ていきましょう。

```tsx
import React from 'react'
import withStyles, { WithStyles, StyleRules } from '@material-ui/core/styles/withStyles'
import { createStyles, Theme } from '@material-ui/core/styles'
import { Typography } from '@material-ui/core'

interface TitleProps extends WithStyles<typeof styles> {}

const styles = (theme: Theme): StyleRules => createStyles({
    root: {
        '& .MuiTypography-subtitle1': {
            fontSize: '1.5em',
            fontWeight: 300
        },
        '& .MuiTypography-h5': {
            fontWeight: 200,
        },
    },
    wrapper: {
        backgroundImage: 'linear-gradient(to right, #000428BB, #004e92BB), url(\'/path/to/image\')',
        backgroundPosition: 'center',
        backgroundRepeat: 'no-repeat',
        backgroundSize: 'cover',
        position: 'relative',
        margin: 0,
        height: '95vh',
        minHeight: '450px',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
    },
    title: {
        padding: '0 20%',
        textAlign: 'center',
        color: theme.palette.common.white,
        [theme.breakpoints.down('xs')]: {
            padding: '0 5%',
        },
    },
})

const TitleSection: React.FC<TitleProps> = ({classes}) => {
    return (
        <section className={classes.root}>
            <div className={classes.wrapper}>
                <div className={classes.title}>
                    <Typography variant="h2">
                        <strong>Title</strong>
                    </Typography>
                </div>
            </div>
        </section>
    )
}

export default withStyles(styles, { withTheme: true })(TitleSection)
```

これをもとに詳しく見ていきましょう。

###### 3.1.1. React Componentの型

React Componentの定義式では通常通り、```React.FC<FooBarProps>``` を使用します。```FooBarProps``` は ```WithStyles<typeof styles>``` を拡張したインタフェースで、プロパティを自由に定義できます。（名前はパスカルケースである限り任意です。）最終的には ```withStyles``` でこのReact Componentをラップしたものをエクスポートします。こうすることで、次項3.1.2で説明するスタイリングが有効となります。

###### 3.1.2. React Componentのスタイリング

スタイリングは基本的に ```styles``` が担います。```styles``` は ```Theme``` 型（3.2.3で詳しく説明）を引数にとり、```StyleRules``` 型を返す関数です。上の例では ```createStyles``` 関数をそのまま返しています。```createStyles``` 関数はCSSライクなオブジェクトを引数にとります。つまり、この関数の引数に各種スタイルを定義していきます。このスタイリングは、コンポーネント内で閉じられており、子コンポーネント以外の別のコンポーネントのスタイルに影響することはありません。

プロパティ名はケバブケースではなく、キャメルケースで記述してください。また、プロパティ名を ```'& .MuiFooBar'``` などとすることでオリジナルのMaterial-UIコンポーネントを上書きすることができます。さらには、```[theme.breakpoints.down('xs')]``` などとすることでブレイクポイントごとのスタイルの変更、すなわち、レスポンシブデザインを可能になります。

定義するだけではスタイルは適用されません。具体的には以下の2つが必要です。

- 前項で説明した通り、```withStyles``` 関数でReact Componentをラップする必要があります。
- スタイルを適用したい要素に ```className``` を通じてスタイルを与えます。このとき、```createStyles``` に渡したオブジェクトがReact Componentの ```props.classes``` に格納されています。

###### 3.1.3. スタイルに ```props``` の内容を反映させたい場合

```makeStyles``` 関数を用います。React Componentの外で、

```tsx
const useStyles = makeStyles({
    foo: (props: FooProps) => ({
        bar: 'baz',
    }),
})
```

のように ```useStyles``` フックを定義します。この中では ```props``` を使用することができ、スタイルに ```props``` の内容を反映できるようになります。あとはReact Component内で

```tsx
const SomeComponent: React.FC<SomeProps> = ({classes, ...props}) => {
    const classesWithProps = useStyles(props)
    return (
        ...
    )
}
```

のように呼び出せば、前項3.1.2.の ```classes``` と同じように、```classesWithProps``` を使用できます。

#### 3.2. 全体のスタイルとテーマについて

###### 3.2.1. テーマ管理

色やフォントファミリー、ブレイクポイントやスペースなどのテーマを管理することができます。これを用いるとダークモードなどを容易に実装できます。```./src/styles``` に次のようなファイルを追加します。

```ts:./src/styles/theme.ts
import { createTheme, responsiveFontSizes } from '@material-ui/core'

/* COLOR */
const black = '#343a40'
const white = '#F4F4F4'
const background = '#F4F4F4'

/* BREAKPOINTS */
const xl = 1920
const lg = 1280
const md = 960
const sm = 600
const xs = 0

/* SPACING */
const spacing = 8

/* FONT FAMILY */
const fontFamily = [
    'Poppins',
    '"Helvetica Neue"',
    'Arial',
    'Noto Sans JP',
    'sans-serif',
    '"Apple Color Emoji"',
    '"Segoe UI Emoji"',
    '"Segoe UI Symbol"',
]

const theme = createTheme({
    palette: {
        primary: {
            main: '#69D2E7',
            light: '#B1E2EC',
            dark: '#51A4B5',
        },
        secondary: {
            main: '#FA6900',
            light: '#FDAD0D',
            dark: '#E3460B',
        },
        common: {
            black: '#343a40',
            white: '#F4F4F4',
        },
        info: {
            main: '#005CAF',
            light: '#0087FC',
            dark: '#00437D',
        },
        success: {
            main: '#00AA90',
            light: '#00F7D2',
            dark: '#007866',
        },
        warning: {
            main: '#FFB11B',
            light: '#FFDB0F',
            dark: '#E8820E',
        },
        error: {
            main: '#CB1B45',
            light: '#D55B78',
            dark: '#991433',
        },
        tonalOffset: 0.2,
        background: {
            default: background,
        },
    },
    spacing: spacing,
    breakpoints: {
        values: {
            xl,
            lg,
            md,
            sm,
            xs
        }
    },
    typography: {
        fontFamily: fontFamily.join(','),
    },
    overrides: {
        MuiToolbar: {
            root: {
                justifyContent: 'space-between'
            }
        },
    },
})

export default responsiveFontSizes(theme)
```

メインカラー、サブカラーの定義などの他に、Material-UIコンポーネントのグローバルな上書き（カスタマイズ）もできます。

これを使用するには

- ```_app.tsx``` 内で ```MuiThemeProvider``` を用いてテーマを提供する必要があります。
    - 渡すテーマを入れ替えれば、テーマが変更できます。
- 各コンポーネントファイルでコンポーネントのラッピングの際に ```withStyles(styles, { withTheme: true })``` のようにオプション引数を与える必要があります。

###### 3.2.2. グローバルスタイル

グローバルに適用したいスタイルにはスタイリングの際に ```@global``` プロパティを与えます。新たに ```GlobalStyles``` というコンポーネントを作り、それを ```_app.tsx``` で呼び出すという方法を私は採用することが多いです。

```tsx:./src/styles/GlobalStyles.tsx
import { withStyles } from "@material-ui/core";
import { Theme } from "@material-ui/core/styles";

type Styles = (theme: Theme) => object

const styles: Styles = theme => ({
    '@global': {
        'html': {
            padding: 0,
            margin: 0,
            scrollBehavior: 'smooth',
        },
        'body': {
            padding: 0,
            margin: 0,
        },
        'a': {
            color: 'inherit',
            textDecoration: 'none',
        },
        '*': {
            boxSizing: 'border-box'
        },
        '.container': {
            width: "100%",
            height: "100%",
            paddingRight: theme.spacing(4),
            paddingLeft: theme.spacing(4),
            margin: "auto",
            [theme.breakpoints.up("sm")]: {
                maxWidth: 540
            },
            [theme.breakpoints.up("md")]: {
                maxWidth: 720
            },
            [theme.breakpoints.up("lg")]: {
                maxWidth: 1170
            }
        },
    }
})

function GlobalStyles() {
    return null;
}

export default withStyles(styles, { withTheme: true })(GlobalStyles);

```

```tsx:./src/pages/_app.tsx
import React, { useEffect } from 'react'
import Head from 'next/head'
import { AppProps } from 'next/app'
import theme from '../styles/theme'
import CssBaseline from '@material-ui/core/CssBaseline'
import GlobalStyles from '../styles/GlobalStyles'
import { MuiThemeProvider } from '@material-ui/core/styles'

export default function PutchApp(props: AppProps) {
    useEffect(() => {
        const jssStyles = document.querySelector('#jss-server-side')
        jssStyles?.parentElement?.removeChild(jssStyles)
    }, [])
    const {Component, pageProps} = props

    return (
        <>
            <Head>
                <title>Title</title>
                <meta charSet="utf-8"/>
                <meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width"/>
            </Head>
            <MuiThemeProvider theme={theme}>
                <CssBaseline/>
                <GlobalStyles/>
                <Component {...pageProps} />
            </MuiThemeProvider>
        </>
    )
}
```

```<CssBaseLine/>``` はCSSをリセットするものです。

## 4. 最後に

ここまでNext.jsアプリでMaterial-UIを使う方法を説明してきました。Material-UIは単にMaterial Designのコンポーネントを提供するだけでなく、テーマやスタイル全体を管理してくれる非常に強力なものです。ぜひ一度、使用を検討してみてはいかがでしょうか。この記事良かったなと思った方は、ぜひLGTMをお願いします。（記事投稿のモチベーションとなります。）

## 5. 参考

#### 5.1. Next.jsについて

###### 公式ドキュメント

https://nextjs.org/docs/getting-started

#### 5.2. Material-UIについて

###### 公式ドキュメント

https://material-ui.com/

###### MUI Treasury

https://mui-treasury.com/