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

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

.eslintrc.cjs の extends に追加:

@@ -5,6 +5,7 @@ module.exports = {
     'eslint:recommended',
     'plugin:@typescript-eslint/recommended',
     'plugin:react-hooks/recommended',
+    'prettier',
   ],
   ignorePatterns: ['dist', '.eslintrc.cjs'],
   parser: '@typescript-eslint/parser',

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 も追加します。

@ts-check をオンにする

.eslintrc.cjs を修正:

@@ -1,3 +1,6 @@
+// @ts-check
+
+/** @type {import('eslint').Linter.Config} */
 module.exports = {
   root: true,
   env: { browser: true, es2020: true },

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

no-floating-promises などのルールが含まれる設定です。

.eslintrc.cjs を修正:

@@ -7,11 +7,15 @@ module.exports = {
   extends: [
     'eslint:recommended',
     'plugin:@typescript-eslint/recommended',
+    'plugin:@typescript-eslint/recommended-type-checked',
     'plugin:react-hooks/recommended',
     'prettier',
   ],
   ignorePatterns: ['dist', '.eslintrc.cjs'],
   parser: '@typescript-eslint/parser',
+  parserOptions: {
+    project: ['./tsconfig.json', './tsconfig.node.json'],
+  },
   plugins: ['react-refresh'],
   rules: {
     'react-refresh/only-export-components': [

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

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

tsconfig.json:

@@ -16,9 +16,10 @@

     /* Linting */
     "strict": true,
-    "noUnusedLocals": true,
-    "noUnusedParameters": true,
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
     "noFallthroughCasesInSwitch": true
   },
   "include": ["src"],
   "references": [{ "path": "./tsconfig.node.json" }]

.eslintrc.cjs:

@@ -22,5 +22,6 @@ module.exports = {
       'warn',
       { allowConstantExport: true },
     ],
+    '@typescript-eslint/no-unused-vars': 'warn',
   },
 }

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

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

tsconfig の compileOptions noUncheckedIndexedAccess を有効にする

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

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

@@ -16,9 +16,10 @@

     /* Linting */
     "strict": true,
     "noUnusedLocals": false,
     "noUnusedParameters": false,
+    "noFallthroughCasesInSwitch": true,
+    "noUncheckedIndexedAccess": true
   },
   "include": ["src"],
   "references": [{ "path": "./tsconfig.node.json" }]

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 を修正:

 {
   "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 を修正:

 {
   "singleQuote": true,
   "semi": false,
-  "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
+  "plugins": [
+    "@trivago/prettier-plugin-sort-imports",
+    "prettier-plugin-tailwindcss",
+    "prettier-plugin-classnames",
+    "prettier-plugin-merge"
+  ],
+  "endingPosition": "absolute-with-indent",
   "tailwindFunctions": ["clsx"]
 }

eslint-plugin-tailwindcss

eslint-plugin-tailwindcss - npm

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

pnpm install -D eslint-plugin-tailwindcss

.eslintrc.cjs を修正:

@@ -9,6 +9,7 @@ module.exports = {
     'plugin:@typescript-eslint/recommended',
     'plugin:@typescript-eslint/recommended-type-checked',
     'plugin:react-hooks/recommended',
+    'plugin:tailwindcss/recommended',
     'prettier',
   ],
   ignorePatterns: ['dist', '.eslintrc.cjs'],
@@ -16,12 +17,13 @@ module.exports = {
   parserOptions: {
     project: ['./tsconfig.json', './tsconfig.node.json'],
   },
-  plugins: ['react-refresh'],
+  plugins: ['react-refresh', 'tailwindcss'],
   rules: {
     'react-refresh/only-export-components': [
       'warn',
       { allowConstantExport: true },
     ],
     '@typescript-eslint/no-unused-vars': 'warn',
+    'tailwindcss/classnames-order': 'off',
   },
 }

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

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.json を修正:

@@ -19,7 +19,10 @@
     "noUnusedLocals": false,
     "noUnusedParameters": false,
     "noFallthroughCasesInSwitch": true,
-    "noUncheckedIndexedAccess": true
+    "noUncheckedIndexedAccess": true,
+
+    /* Vitest */
+    "types": ["vitest/globals"]
   },
   "include": ["src"],
   "references": [{ "path": "./tsconfig.node.json" }]

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

tsconfig.json を修正:

@@ -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.json を修正:

@@ -24,6 +24,6 @@
     /* Vitest */
     "types": ["vitest/globals"]
   },
-  "include": ["src"],
+  "include": ["src", "vitest-setup.ts"],
   "references": [{ "path": "./tsconfig.node.json" }]
 }

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);
});