2024-04-13 Updated: eslint-plugin-tailwindcss の章を追加

2024-09-25 Updated: eslint flat config に対応

Vite で TypeScript の React プロジェクトを作る手順のメモです。

Tailwind や Redux など常に必要なわけではないライブラリも含まれるのでご注意ください。

プロジェクト作成

以前は Create React App というツールが使われていましたが、現在ではメンテナンスされていないようです。

公式 には Next.jsRemix が推奨されていますが、フレームワークを使わずに始めたい場合は Vite がよく使われるようです。

テンプレートに react-ts を指定して作成します。

pnpm create vite@latest --template react-ts <app-name>

.node-version ファイルを作成しておきます。

# .node-version を作成
node -v > .node-version
# もしくは major version のみ記載する場合
node -p 'process.versions.node.split(".")[0]' > .node-version

Prettier

Install · Prettier

コードフォーマッターです。テンプレートのスタイルに合わせて singleQuote と semi を設定します。

pnpm install -D prettier eslint-config-prettier

echo '{
  "singleQuote": true,
  "semi": false
}' > .prettierrc

echo pnpm-lock.yaml > .prettierignore

eslint.config.js に追加:

@@ -3,6 +3,7 @@ import globals from 'globals'
 import reactHooks from 'eslint-plugin-react-hooks'
 import reactRefresh from 'eslint-plugin-react-refresh'
 import tseslint from 'typescript-eslint'
+import eslintConfigPrettier from 'eslint-config-prettier'

 export default tseslint.config(
   { ignores: ['dist'] },
@@ -25,4 +26,5 @@ export default tseslint.config(
       ],
     },
   },
+  eslintConfigPrettier,
 )

package.json の scripts にコマンドを追加:

@@ -7,7 +7,8 @@
     "dev": "vite",
     "build": "tsc && vite build",
     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "format": "prettier --write ."
   },
   "dependencies": {
     "react": "^18.2.0",

フォーマットを適用します。

❯ pnpm run format

@trivago/prettier-plugin-sort-imports

https://github.com/trivago/prettier-plugin-sort-imports

import をソートするプラグインです。

pnpm install -D @trivago/prettier-plugin-sort-imports

.pretterrc を修正:

@@ -1,4 +1,6 @@
 {
   "singleQuote": true,
-  "semi": false
+  "semi": false,
+  "plugins": ["@trivago/prettier-plugin-sort-imports"],
+  "importOrder": ["<THIRD_PARTY_MODULES>", "^[./]"]
 }

ESLint の追加設定

テンプレートの ESLint の設定はいくつかの recommended 設定が最初から有効ですが、 @typescript-eslint/recommended-type-checked も追加します。

optional: @ts-check をオンにする

eslint.config.js の先頭に @ts-check を追加します。

@@ -1,3 +1,4 @@
+// @ts-check
 import js from '@eslint/js'
 import eslintConfigPrettier from 'eslint-config-prettier'
 import reactHooks from 'eslint-plugin-react-hooks'

ただし、これを執筆している時点 (2024-09-25) では @ts-check を追加すると react-hooksrules の箇所でコンパイルエラーが表示されます。 以下のいずれかの対応を選択することになります。

  • @ts-check を追加しない
    • エラーを検出できなくなるが、気にしないという判断。
    • 追加しなくても、無効な設定を書いてしまった場合は ESLint がエラーを出すので気付ける。
    • 追加しなくとも、 tseslint.config() の効果 でエディタの TypeScript 補完は効く。
  • 追加した上で、エラーを無視する
    • VSCode などエディタ上でエラーになるだけで、ビルドなどは問題ない。したがってエラーが出る状態にしておき、単に無視する。
    • 将来的にプラグイン側で対応されたら解消するはず。

@typescript-eslint/recommended-type-checked をオンにする

通常の @typescript-eslint/recommended のルールに加え、 TypeScript の型情報を使う設定です。 no-floating-promises などのルールが含まれます。

eslint.config.js を修正:

@@ -9,7 +9,10 @@ import tseslint from 'typescript-eslint'
 export default tseslint.config(
   { ignores: ['dist'] },
   {
-    extends: [js.configs.recommended, ...tseslint.configs.recommended],
+    extends: [
+      js.configs.recommended,
+      ...tseslint.configs.recommendedTypeChecked,
+    ],
     files: ['**/*.{ts,tsx}'],
     languageOptions: {
       ecmaVersion: 2020,

parserOptions.project を追加

以下のエラーが起きるようになります。

Oops! Something went wrong! :(

ESLint: 9.11.1

Error: Error while loading rule '@typescript-eslint/await-thenable': You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.
Parser: typescript-eslint/parser
Occurred while linting (snip)/src/main.tsx
# snip
 ELIFECYCLE  Command failed with exit code 2.

eslint.config.js に以下の設定を追加すると解消します。

export default tseslint.config(
  { ignores: ["dist"] },
  {
    /* snip */
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        // languageOptions の子として追加
        project: ["./tsconfig.app.json", "./tsconfig.node.json"],
      },
    },
    /* snip */
  },
  eslintConfigPrettier
);

tsconfig の noUnused… をオフ + ESLint の @typescript-eslint/no-unused-vars を warn に

noUnusedLocals と noUnusedParameters を無効にし、 ESLint の @typescript-eslint/no-unused-vars を warn にします。

tsconfig.app.json:

@@ -16,8 +16,8 @@

     /* Linting */
     "strict": true,
-    "noUnusedLocals": true,
-    "noUnusedParameters": true,
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
     "noFallthroughCasesInSwitch": true
   },
   "include": ["src"]

eslint.config.js:

export default tseslint.config(
  { ignores: ["dist"] },
  {
    /* snip */
    rules: {
      /* snip */
      "@typescript-eslint/no-unused-vars": "warn", // rules の子として追加
    },
  },
  eslintConfigPrettier
);

この設定は tsconfig と ESLint で重複するため ESLint に任せることにします。また新しい変数を書いたそばからエラーになるのは邪魔に感じるため、個人的には error でなく warn にしたい。

@typescript-eslint/no-unused-vars は recommended 設定だと error に設定されているため、 warn に変更しています。この場合 CI で warn を許さないようにチェックするとよいでしょう。

例: CI では error レベルにし、かつ _ 始まりの変数は許容する設定

const isCI = process.env.CI;

export default tseslint.config(
  { ignores: ["dist"] },
  {
    /* snip */
    rules: {
      /* snip */
      "@typescript-eslint/no-unused-vars": [
        isCI ? "error" : "warn",
        {
          argsIgnorePattern: "_",
          varsIgnorePattern: "^_+$",
        },
      ],
    },
  },
  eslintConfigPrettier
);

tsconfig の compileOptions noUncheckedIndexedAccess を有効にする

noUncheckedIndexedAccess | TypeScript 入門『サバイバル TypeScript』

"strict": true で有効にならないオプションですが、配列を安全に扱うのに有用なので設定しておきます。

@@ -18,7 +18,8 @@
     "strict": true,
     "noUnusedLocals": false,
     "noUnusedParameters": false,
-    "noFallthroughCasesInSwitch": true
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedIndexedAccess": true
   },
   "include": ["src"]
 }

Tailwind CSS

Install Tailwind CSS with Vite - Tailwind CSS

CSS フレームワークです。

pnpm install -D tailwindcss postcss autoprefixer

# 設定ファイルは TypeScript を選択
pnpx tailwindcss init --ts --postcss

tailwind.config.ts を修正:

 import type { Config } from 'tailwindcss'

 export default {
-  content: [],
+  content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
   theme: {
     extend: {},
   },
   plugins: [],
 } satisfies Config

index.css を修正:

@tailwind base;
@tailwind components;
@tailwind utilities;

tsconfig.node.json に tailwind ファイルを含める

この状態で lint を実行すると以下のエラーが発生します。

pnpm run lint
#(snip)
<snip>/tailwind.config.ts
  0:0  error  Parsing error: ESLint was configured to run on `<tsconfigRootDir>/tailwind.config.ts` using `parserOptions.project`:
- <snip>/tsconfig.json
- <snip>/tsconfig.node.json

tsconfig.node.json の include に tailwind.config.ts を追加すると解消します。

@@ -7,5 +7,5 @@
     "allowSyntheticDefaultImports": true,
     "strict": true
   },
-  "include": ["vite.config.ts"]
+  "include": ["vite.config.ts", "tailwind.config.ts"]
 }

prettier-plugin-tailwindcss

Editor Setup - Tailwind CSS https://github.com/tailwindlabs/prettier-plugin-tailwindcss

className をソートする Tailwind 公式の Prettier プラグインです。

pnpm install -D prettier-plugin-tailwindcss

.prettierrc を修正:

@@ -1,6 +1,9 @@
 {
   "singleQuote": true,
   "semi": false,
-  "plugins": ["@trivago/prettier-plugin-sort-imports"],
+  "plugins": [
+    "@trivago/prettier-plugin-sort-imports",
+    "prettier-plugin-tailwindcss"
+  ],
   "importOrder": ["<THIRD_PARTY_MODULES>", "^[./]"]
 }

clsx

https://github.com/lukeed/clsx

クラス名を結合する関数を提供するライブラリで、条件付きでクラスを切り替えたりする場合に使います。 同種のツールでより高機能な tailwind-merge が存在しますが、今回は clsx を選択。

pnpm install clsx

.pretterrc を修正:

   "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
-  "importOrder": ["<THIRD_PARTY_MODULES>", "^[./]"]
+  "importOrder": ["<THIRD_PARTY_MODULES>", "^[./]"],
+  "tailwindFunctions": ["clsx"]
 }

prettier-plugin-classnames

長いクラス名を改行する Prettier プラグインです。詳細は prettier-plugin-classnames でクラス名を改行する | okiyama.dev を参照。

pnpm install -D prettier-plugin-classnames prettier-plugin-merge

.prettierrc を修正:

@@ -3,8 +3,11 @@
   "semi": false,
   "plugins": [
     "@trivago/prettier-plugin-sort-imports",
-    "prettier-plugin-tailwindcss"
+    "prettier-plugin-tailwindcss",
+    "prettier-plugin-classnames",
+    "prettier-plugin-merge"
   ],
   "importOrder": ["<THIRD_PARTY_MODULES>", "^[./]"],
-  "tailwindFunctions": ["clsx"]
+  "tailwindFunctions": ["clsx"],
+  "endingPosition": "absolute-with-indent"
 }

eslint-plugin-tailwindcss

eslint-plugin-tailwindcss - npm

ESLint プラグインです。 Tailwind のクラス名以外を検出などのルールがあります。

pnpm install -D eslint-plugin-tailwindcss

eslint.config.js を修正:

// import 追加
import tailwind from "eslint-plugin-tailwindcss";

/* snip */

export default tseslint.config(
  { ignores: ["dist"] },
  ...tailwind.configs["flat/recommended"], // 追加
  {
    /* snip */
    rules: {
      /* snip */
      "tailwindcss/classnames-order": "off", // rule 追加
    },
  },
  eslintConfigPrettier
);

rules で tailwindcss/classnames-orderoff にしているのは、クラス名のソートは prettier-plugin-tailwindcss に任せるためです。

Redux Toolkit, react-redux

Installation | Redux

ステート管理ライブラリです。 Redux Toolkit が出てから以前にもまして重量級の雰囲気ですが、 Toolkit の流儀に従っておけばボイラープレートも少なくシンプルに書けるようになっています。

redux@reduxjs/toolkit の依存に含まれているため個別にインストールする必要はなく、このふたつだけでよいです。

pnpm install @reduxjs/toolkit react-redux

src/redux/store.ts を作成:

import { configureStore } from "@reduxjs/toolkit";

export const rootReducer = {};

export const setupStore = () => {
  const store = configureStore({
    reducer: rootReducer,
  });
  // TODO: hot reloading の設定
  return store;
};

type AppStore = ReturnType<typeof setupStore>;
export type AppState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];

src/redux/hooks.ts を作成:

import { useDispatch, useSelector } from "react-redux";
import { AppDispatch, AppState } from "./store";

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<AppState>();

src/main.tsx を修正:

@@ -2,9 +2,15 @@ import React from 'react'
 import ReactDOM from 'react-dom/client'
 import App from './App.tsx'
 import './index.css'
+import { Provider } from 'react-redux'
+import { setupStore } from './redux/store.ts'
+
+const store = setupStore()

 ReactDOM.createRoot(document.getElementById('root')!).render(
   <React.StrictMode>
-    <App />
+    <Provider store={store}>
+      <App />
+    </Provider>
   </React.StrictMode>,
 )

課題: Redux のホットリロード

ホットリロードの設定方法が書かれていますが、 Vite だとうまく動作しませんでした。

Configuring Your Store | Redux

このように設定してみたのですが、ファイル保存時にページ全体がリロードされてしまう。未解決です。

// rootReducer を ./reducers.ts に移動したうえで以下を追加
if (import.meta.env.DEV && import.meta.hot) {
  import.meta.hot.accept("./reducers", async () =>
    store.replaceReducer((await import("./reducers")).rootReducer)
  );
}

関連するかもしれない Discussion: https://github.com/reduxjs/redux-toolkit/discussions/4281

React Router

Tutorial v6.22.3 | React Router

ルーターライブラリです。この例は React Router ですが、今新しく始めるなら TanStack Router もよいかもしれません。

pnpm install react-router-dom

main.tsx を修正:

 import React from 'react'
 import ReactDOM from 'react-dom/client'
 import { Provider } from 'react-redux'
+import { RouterProvider, createBrowserRouter } from 'react-router-dom'
 import App from './App.tsx'
 import './index.css'
 import { setupStore } from './redux/store.ts'

 const store = setupStore()

+const router = createBrowserRouter([
+  {
+    path: '/',
+    element: <App />,
+  },
+])
+
 ReactDOM.createRoot(document.getElementById('root')!).render(
   <React.StrictMode>
     <Provider store={store}>
-      <App />
+      <RouterProvider router={router} />
     </Provider>
   </React.StrictMode>,
 )

Vitest

Getting Started | Guide | Vitest

テストフレームワークです。 DOM ライブラリである happy-dom もインストールします。

pnpm install -D vitest happy-dom

package.json の scripts にコマンドを追加:

@@ -8,7 +8,8 @@
     "build": "tsc -b && vite build",
     "lint": "eslint .",
     "preview": "vite preview",
-    "format": "prettier --write ."
+    "format": "prettier --write .",
+    "test": "vitest"
   },
   "dependencies": {
     "clsx": "^2.1.1",

vitest.config.ts を作成:

import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      globals: true,
      environment: "happy-dom",
    },
  })
);

globals: true の設定で describe, test などをインポートせずに使えるようになります。この設定を使う場合は tsconfig の設定も必要です。

tsconfig.app.json を修正:

@@ -19,7 +19,10 @@
     "noUnusedLocals": false,
     "noUnusedParameters": false,
     "noFallthroughCasesInSwitch": true,
-    "noUncheckedIndexedAccess": true
+    "noUncheckedIndexedAccess": true,
+
+    /* Vitest */
+    "types": ["vitest/globals"]
   },
   "include": ["src"]
 }

また、このファイルも tsconfig.node.jsoninclude に追加しておきます。

@@ -7,5 +7,5 @@
     "allowSyntheticDefaultImports": true,
     "strict": true
   },
-  "include": ["vite.config.ts", "tailwind.config.ts"]
+  "include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.ts"]
 }

React Testing Library, user-event, jest-dom

テストコードでの DOM 要素の取得やアサーションに使うライブラリです。

pnpm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom

vitest-setup.ts を追加:

import "@testing-library/jest-dom/vitest";

vitest.config.ts を修正:

@@ -7,6 +7,7 @@ export default mergeConfig(
     test: {
       globals: true,
       environment: 'happy-dom',
+      setupFiles: ['./vitest-setup.ts'],
     },
   }),
 )

tsconfig.node.json を修正:

@@ -18,5 +18,10 @@
     "noUnusedParameters": true,
     "noFallthroughCasesInSwitch": true
   },
-  "include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.ts"]
+  "include": [
+    "vite.config.ts",
+    "vitest.config.ts",
+    "vitest-setup.ts",
+    "tailwind.config.ts"
+  ]
 }

テストコード

以下のようにテストします。

src/App.test.tsx:

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { App } from "./App";

const user = userEvent.setup();

it("App", async () => {
  render(<App />);
  await user.click(screen.getByText("count is 0"));
  await waitFor(() => {
    screen.getByText("count is 1");
  });
});

MSW

Mock Service Worker - API mocking library for browser and Node.js

テストで HTTP API, GraphQL API をモック化するのに使います。 React Testing Library のドキュメント でも推奨されていました。

pnpm install -D msw@latest

src/__mocks__/server.ts を作成:

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

export const server = setupServer(
  http.get("https://example.com/greeting", () => {
    return HttpResponse.json({ greeting: "hello there" });
  })
);

src/setupTests.ts を作成:

import { server } from "./__mocks__/server";

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

vitest-setup.ts に追記:

@@ -1 +1,6 @@
 import '@testing-library/jest-dom/vitest'
+import { server } from './src/__mocks__/server'
+
+beforeAll(() => server.listen())
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())

以下のようにテストします。

import { HttpResponse, http } from "msw";
import { server } from "../__mocks__/server";

it("api success", async () => {
  const res = await fetch("https://example.com/greeting");
  expect(await res.json()).toStrictEqual({ greeting: "hello there" });
});

it("api error", async () => {
  server.use(
    http.get("https://example.com/greeting", () => {
      return new HttpResponse(null, { status: 500 });
    })
  );
  const res = await fetch("https://example.com/greeting");
  expect(res.status).toBe(500);
});