見出し画像

’use client’の付け忘れを未然に防ぐESLintのカスタムルールを作成した話(Next.js 13)

こんにちは、Webエンジニアのtomoです。

Next.js 13を使ってフロントエンド開発をしていると、`’use client’` という一文をしばしば見かけるかと思います。

こいつが厄介でして、 MUIなどをインポートしているファイルで、先頭に `’use client’` を付け忘れた状態でレンダリングすると、以下のようなエラーが発生するんですね。。

TypeError: createContext only works in Client Components. 
Add the "use client" directive at the top of the file to use it. 
Read more: https://nextjs.org/docs/messages/context-in-server-component

エラーが出て、「え?createContextなんて使ってないんだけど?」と焦って、同僚に確認したところ、

「MUI使ってたら `’use client’` つけないとダメだよ」

というアドバイスをいただき、なんとかエラーを解決することができました。

単純なエラー解決に1,2時間持っていかれたので、「次からは仕組みで気づけるようにしたい!」と思い、色々調べてみました。

するとどうやら、ESLintのカスタムルールというのを使えば、コードをちょっと追加するだけで、未然に防げるらしいじゃないですか!

ということで、MUIをインポートしているにもかかわらず `’use client’` を付け忘れてしまったときに、すぐ叱ってくれるESLintを実装したので、共有します。

'use client'のつけ忘れを未然に防ぐESLintのカスタムルール

そもそも、なぜ’use client’が必要なの?

Next.js 13からの新機能として筆頭なのが、Server ComponentsとClient Components(以下「SCとCC」という)です。

簡単に解説すると、

「コンポーネントによっては、JSをクライアントで実行する必要ないよね?」

「じゃあ、JSをクライアントで実行する必要がないコンポーネントは、HTMLとCSSをサーバー側で用意して返せば、パフォーマンス上がるよね?」

「クライアントで実行が必要なJSがないものをSC、 実行が必要なものをCCとしよう」

というパフォーマンスアップのための仕組みです。

以上のように、Next.js ではコンポーネントはSCとCCの2つに分けられており、CCであることを表すために `’use client’` をつけなければいけません。*1

さて、ここで考えなければならないのが、MUIはどうなのか、ということなのですが、結論、CCとして扱わないといけません。*2

なぜなら、MUIはReactのContext APIを利用してテーマを共有しているからです。

具体的には、MUIはcreateContextを使用しており、この機能はCCでなければ利用できません。*3

よって、一般的なルールとして、「MUIをインポートする際は必ず ’use client’ を付ける」と覚えておけば問題ありません。

まず結論から。それESLintのカスタムルールでできるよ!

さて、いよいよ本題に入っていくのですが、結論としてはESLintのカスタムルールが最も効果的な解決策であると考えています!

なぜなら、ESLintを使用すれば、エディタの設定によりファイル編集時にエラーを即座に表示してくれるため、開発中に`’use client’` のつけ忘れに気づくことができるからです。

レビュー時やCIでチェックするのでもできなくはないんですけど、どうしても気づくのが遅れてしまいます。*4

では、まずESLintのカスタムルールを書くために、色々セットアップしていきましょう!*5

動作確認済みエディタ:Visual Studio Code、inteliJ、neovim

今回は、eslint-plugin-local-rulesというプラグインを使ってセットアップすることにします。

$ npm install --save-dev eslint-plugin-local-rules

インストールできたら、`./eslint-local-rules` ディレクトリ配下に `index.js` ファイルを作成していきます。*6

// ./eslint-local-rules/index.js
module.exports = {
  'check-use-client': {
   // ここに今回作成したいカスタムルールを記述していく
  }
}

最後に、`.eslintrc.js` ファイルでプラグインを読み込めば、セットアップはこれで完了です。

// ./.eslintrc.js
module.exports = {
  root: true,
  plugins: ['local-rules'] 
  overrides: [
    {
      files: ['**/*.tsx'],
      // storybookやjestを使用しており、それらのファイルでは'use client'をつける必要がないため、excludeしている。
      excludedFiles: [
        '**/*.stories.tsx',
        '**/*.test.tsx',
        // 他にexcludeしたいファイルがあれば、ここにglobパターンを使って追加する。
      ],
      rules: {
        'local-rules/check-use-client': 'error'
      }
    }
  ]
}

実際にESLintのカスタムルールを作成していく!

はじめに、今回作成したカスタムルールの全体像をどうぞ!

// ./eslint-local-rules/index.js
module.exports = {
  'check-use-client': {
    meta: {
      type: 'problem',
      fixable: 'code'
    },
    create: function (context) {
      let muiImported = false
      const useClientNodes = []
      return {
        ImportDeclaration(node) {
          if (node.source.value.startsWith('@mui/')) {
            muiImported = true
          }
        },
        ExpressionStatement(node) {
          if (node.expression.type === 'Literal' && node.expression.value === 'use client') {
            useClientNodes.push(node)
          }
        },
        'Program:exit': function (node) {
          // NOTE: muiがimportされていない場合はチェックしない
          if (!muiImported) return
          if (useClientNodes.length === 0) {
            // 'use client'が1つもない場合、エラーを報告し、'use client'をファイル先頭に追加する修正を提案する。
            context.report({
              node: node,
              message:
                'When importing from a module that starts with @mui/, "use client" must be placed at the beginning of the file',
              fix(fixer) {
                return fixer.insertTextBefore(node.body[0], "'use client'\n\n")
              }
            })
          } else if (useClientNodes.length === 1) {
            // 'use client'が1つだけの場合
            let firstStatement = node.body.find(
              // 最初のステートメントを取得(空行、コメントは除く)
              statement =>
                statement.type !== 'EmptyStatement' &&
                statement.type !== 'CommentBlock' &&
                statement.type !== 'CommentLine'
            )
            // 最初のステートメントが'use client'でない場合、エラーを報告し、'use client'をファイル先頭に移動する修正を提案する。
            if (firstStatement !== useClientNodes[0]) {
              context.report({
                node: useClientNodes[0],
                message:
                  'When importing from a module that starts with @mui/, "use client" must be placed at the beginning of the file',
                fix(fixer) {
                  return [fixer.insertTextBefore(node.body[0], "'use client'\n\n"), fixer.remove(useClientNodes[0])]
                }
              })
            }
          } else {
            // 'use client'が2つ以上の場合、エラーを報告し、2つ目以降の'use client'を削除する修正を提案する。
            context.report({
              node: useClientNodes[1],
              message: 'Only one "use client" directive is allowed at the beginning of the file',
              fix(fixer) {
                return useClientNodes.slice(1).map(node => fixer.remove(node))
              }
            })
          }
        }
      }
    }
  }
}

同じコードは以下のリンクでも、ご覧いただけます。
https://gist.github.com/tomo-kn/d2d3ab929cdbf01e87abcb70bcfd1f68

ちょっと長くて「うっ」ってなるのですが、重要な部分はそこまで多くはありません。
順に見ていきます。

まず、以下の2つの変数宣言がとても重要で、

let muiImported = false
const useClientNodes = []

これらの変数で、「MUIがインポートされているかどうか」や「ファイルに記述されている `’use client’`」を調べます。

具体的に調べているのは、

return {
  ImportDeclaration(node) {
    if (node.source.value.startsWith('@mui/')) {
      muiImported = true
    }
  },
  ExpressionStatement(node) {
    if (node.expression.type === 'Literal' && node.expression.value === 'use client') {
      useClientNodes.push(node)
    }
  },

↑の部分ですが、これを理解するにはAST(Abstract Syntax Tree、抽象構文木)という、ソースコードをツリーで表現したものを知っている必要があります。
詳しくは後述します。

さて、`Program:exit` はプログラム終了時に呼ばれる処理になります。

if (!muiImported) return

そもそもMUIがインポートされていない場合、何もしなくてOKなので、アーリーリターンしています。

次に、`’use client’` の数で場合分けして処理を行うわけですが、「0個」、「1個」「2個以上」の場合に分けて処理を書いています。

パターン1:0個の場合は、

return fixer.insertTextBefore(node.body[0], "'use client'\n\n")

と、単純に先頭に `’use client’` を付け足すQuick Fixを設定すればOKです。

パターン2:ややこしいのは1個の場合で、たとえば

// ここにコメント
'use client'

import { Box } from '@mui/material'

のように、`’use client’` の前にコメントや空行があってもOKなのですが、

import { Box } from '@mui/material'

'use client'

このように、`’use client’` の前にimport文がある場合、当然ながら「`’use client’` はファイル先頭に書く」というNext.jsの規約に違反して、エラーが出てしまいます。

つまり、 `’use client’` が1個の場合は、場所がとても重要になってくるので、

let firstStatement = node.body.find(
  // 最初のステートメントを取得(空行、コメントは除く)
  statement =>
    statement.type !== 'EmptyStatement' &&
    statement.type !== 'CommentBlock' &&
    statement.type !== 'CommentLine'
)
// 最初のステートメントが'use client'でない場合、エラーを報告し、'use client'をファイル先頭に移動する修正を提案する。
if (firstStatement !== useClientNodes[0])

まず空行・コメントを除く最初のステートメントを取得し、それが `useClientNodes[0]` と一致するかどうか、をチェックします。

これらが一致しないということは、つまり `’use client’` がファイル先頭にないということになるので、

return [fixer.insertTextBefore(node.body[0], "'use client'\n\n"), fixer.remove(useClientNodes[0])]

ファイル先頭に `’use client’` を挿入しながら、ファイル途中の `’use client’` は削除するQuick Fixを設定しています。

パターン3:最後に、`’use client’` が2個以上ある場合は、2個目以降を削除すれば、1個目の場合のチェックが走るので、

return useClientNodes.slice(1).map(node => fixer.remove(node))

これで、2個目以降の `’use client’` を全て削除するQuick Fixを設定しています。

このQuick Fixのあと、再度ESLintが実行されるので、「パターン2」が実行され、意図した状態に変更されます。

コードの解説は以上ですが、実装のより詳細部分を知りたい方は、ESLintカスタムルールの公式ドキュメントをご覧ください。

ASTの話

ASTというのは、コードをツリー表現したもので、大体のプログラミング言語がコードを一回ASTに変換してからコードを実行しています。

JavaScriptでASTを確認する方法は色々ありますが、今回は Acorn というライブラリを使ってみます。
まずはインストールします。

$ npm install acorn

次に、今回`’use client’` とimport文のASTが見たいので、`./src/index.tsx` を以下のように作成します。

// ./src/index.tsx
'use client'

import { Box } from '@mui/material'

そして、同じディレクトリに `parse.js` を以下のように作成します。

// ./src/parse.js
const acorn = require("acorn");
const fs = require('fs')
const path = require('path')

const filePath = path.join(__dirname, './index.tsx')
const code = fs.readFileSync(filePath, 'utf-8')
const ast = acorn.parse(code, {
  sourceType: "module",
  ecmaVersion: 2020
});

console.log(JSON.stringify(ast, null, 2));

あとは、以下のコマンドでASTを出力できます。

$ node src/parse.js

出力結果がこちら

{
  "type": "Program",
  "start": 0,
  "end": 50,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 12,
      "expression": {
        "type": "Literal",
        "start": 0,
        "end": 12,
        "value": "use client",
        "raw": "'use client'"
      },
      "directive": "use client"
    },
    {
      "type": "ImportDeclaration",
      "start": 14,
      "end": 49,
      "specifiers": [
        {
          "type": "ImportSpecifier",
          "start": 23,
          "end": 26,
          "imported": {
            "type": "Identifier",
            "start": 23,
            "end": 26,
            "name": "Box"
          },
          "local": {
            "type": "Identifier",
            "start": 23,
            "end": 26,
            "name": "Box"
          }
        }
      ],
      "source": {
        "type": "Literal",
        "start": 34,
        "end": 49,
        "value": "@mui/material",
        "raw": "'@mui/material'"
      }
    }
  ],
  "sourceType": "module"
}

この出力結果を参考に、ESLintのカスタムルールの主要部分を抽出すると以下のようになります。

...
ImportDeclaration(node) {
  if (node.source.value.startsWith('@mui/')) {
    muiImported = true
  }
},
ExpressionStatement(node) {
  if (node.expression.type === 'Literal' && node.expression.value === 'use client') {
    useClientNodes.push(node)
  }
},
...

カスタムルールが正しいかテストする

実装は完了しましたが、ちゃんと意図通りに正しく動作するかテストすることも忘れないようにしましょう。

`./eslint-local-rules/check-use-client.test.ts` というファイルを、以下のように作成します。

// ./eslint-local-rules/check-use-client.test.ts
const RuleTester = require('eslint').RuleTester
const rule = require('./index')['check-use-client']

const ruleTester = new RuleTester({
  parserOptions: { ecmaVersion: 6, sourceType: 'module' }
})

ruleTester.run('check-use-client', rule, {
  valid: [
    {
      code: "'use client'\nimport { Box } from '@mui/material'"
    },
    {
      code: "'use client'\nimport { Web } from '@mui/icons-material'"
    },
    {
      code: "import { useState } from 'react'"
    }
  ],
  invalid: [
    {
      code: "import { Box } from '@mui/material'",
      output: "'use client'\n\nimport { Box } from '@mui/material'",
      errors: [
        {
          message:
            'When importing from a module that starts with @mui/, "use client" must be placed at the beginning of the file',
          type: 'Program'
        }
      ]
    },
    {
      code: "import { Web } from '@mui/icons-material'",
      output: "'use client'\n\nimport { Web } from '@mui/icons-material'",
      errors: [
        {
          message:
            'When importing from a module that starts with @mui/, "use client" must be placed at the beginning of the file',
          type: 'Program'
        }
      ]
    },
    {
      code: "import { Box } from '@mui/material'\n'use client'",
      output: "'use client'\n\nimport { Box } from '@mui/material'\n",
      errors: [
        {
          message:
            'When importing from a module that starts with @mui/, "use client" must be placed at the beginning of the file',
          type: 'ExpressionStatement'
        }
      ]
    },
    {
      code: "'use client'\nimport { Box } from '@mui/material'\n'use client'",
      output: "'use client'\nimport { Box } from '@mui/material'\n",
      errors: [
        {
          message: 'Only one "use client" directive is allowed at the beginning of the file',
          type: 'ExpressionStatement'
        }
      ]
    }
  ]
})

このテストは、ESLintのRuleTesterを使って実装しています。

JSのimportはES6からの仕様なので、`parserOptions: { ecmaVersion: 6, sourceType: 'module' }` を忘れずにつけてください。

テストを実行すると以下のようになります。

$ yarn jest

check-use-client
    valid
      ✓ 'use client'
import { Box } from '@mui/material' (14 ms)
      ✓ 'use client'
import { Web } from '@mui/icons-material' (2 ms)
      ✓ import { useState } from 'react' (1 ms)
    invalid
      ✓ import { Box } from '@mui/material' (3 ms)
      ✓ import { Web } from '@mui/icons-material' (2 ms)
      ✓ import { Box } from '@mui/material'
'use client' (3 ms)
      ✓ 'use client'
import { Box } from '@mui/material'
'use client' (2 ms)

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Snapshots:   0 total
Time:        4.117 s

良さそうですね!

実施効果

今回作成したカスタムルールのおかげで、MUIからインポートしている時、`’use client’` の個数や位置に応じてlintエラーを出して、Quick Fixしてくれるようになりました。

ESLintカスタムルール完成

チームでも好評で、「これはいい!」と絶賛でした🎊

これでもう `’use client’` をつけ忘れて困ることは、一生なくなりますね!!

まとめ

MUIなどのUIライブラリを使うと、`’use client’` のつけ忘れによるエラーが起こることがあるかと思います。

が、ESLintのカスタムルールを作ることで、簡単に`’use client’`のつけ忘れを未然に防ぐことができます!

今後の展望としては、他のUIライブラリでも同様の問題が発生する可能性があると考えています。

そのため、他のUIライブラリでも利用できるように、このカスタムルールをnpmパッケージ化して公開するのも面白そうですね!

みなさま、よきESLintライフを!

参考文献

*1:厳密に言えば、`’use client’` をファイル先頭に書かなければいけないのは、SCとの境界に位置するCCのみです。しかし、境界を意識したApp全体の構成やESLintのルール作成が難しいため、本記事では全てのCCに `’use client’` をつける実装方針をとっています。https://nextjs.org/docs/getting-started/react-essentials#the-use-client-directive

*2:https://github.com/mui/material-ui/issues/37503 のプルリクエストによって MUI v5.14.0 で多くのMUIコンポーネントに ‘use client’ がついたので、将来的には本記事の対応は不要になる可能性があります。

*3:参考文献3つ
https://mui.com/material-ui/customization/theming/
https://nextjs.org/docs/getting-started/react-essentials#third-party-packages
https://nextjs.org/docs/getting-started/react-essentials#context

*4:@mui/materialをexportするファイルを作り、そこに `’use client’` をつけてimportする、というやり方もあるのですが、あくまで一時的な回避策であるため、今回はESLintのカスタムルールを採用しています。https://github.com/mui/material-ui/issues/34898#issuecomment-1511284921

*5:ちなみに「eslint-plugin」でいい感じのnpmがないか調べてみましたが、発見できませんでした。https://www.npmjs.com/search?q=eslint-plugin

*6:フォルダやファイル名が正しくないとうまく動作してくれないので、注意が必要です。https://github.com/cletusw/eslint-plugin-local-rules

nocoにご興味があれば

カジュアル面談や採用応募の情報はこちらまで!