かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React の再レンダリングについてのメモ

React のコンポーネントレンダリング (Re-rendering) について試したのでメモ

前提: 親コンポーネントの state が更新されたら子コンポーネントは再レンダリングされる

import { FC, memo, useState } from 'react';

// 親と依存関係のないコンポーネント
const MyComponent: FC = () => {
  return <div>MyComponent</div>;
};

// 親から props が渡されるコンポーネント
type ButtonProps = {
  onClick: () => void;
  children: React.ReactNode;
};
const Button: FC<ButtonProps> = ({ onClick, children }) => {
  return <button type="button" onClick={onClick}>{children}</button>;
};

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);
  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <Button onClick={handleIncrement}>increment</Button>
      <MyComponent />
    </div>
  );
};

count が更新される度に Counter が再描画されその際に props が渡される <Button> も依存関係のない <MyComponent> も再描画される

props を受け取るコンポーネント (React.memouseCallback)

React.memo しても再レンダリングされるケース

propsObject.is で比較されるので onClick のイベントハンドラのようなオブジェクトはレンダリング時に再生成されるので、メモされたオブジェクトとは異なるので React.memo されたコンポーネントも再レンダリングされてしまう

import { FC, memo, useState } from 'react';

type ButtonProps = {
  onClick: () => void;
  children: React.ReactNode;
};
const Button: FC<ButtonProps> = ({ onClick, children }) => {
  return <button type="button" onClick={onClick}>{children}</button>;
};
// memo化
const MemoButton = memo(Button);

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);
  const handleIncrement = () => {
    setCount((prevCount) => prevCount + 1);
  };

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      <MyComponent />
    </div>
  );
};

上記の例では <Button>React.memo でメモ化 (Memoization) していますが、<Counter> コンポーネントが再レンダリングされる度に、handleIncrement が再生成されるので <Counter> の再レンダリング時に <Button> も再レンダリングされてしまいメモ化の意味がありません

props のオブジェクトを useMemo, useCallback でメモ化する

オブジェクトを props として渡す場合、常に変化すつる訳ではないオブジェクトであれば useMemo, useCallback を使うことでメモされた同一のオブジェクトを渡すことができるようになる
先の例では const handleIncrement = () => voiduseCallback でメモ化することで <Button> コンポーネントに同じオブジェクトとして渡すことができる

- import { FC, memo, useState } from 'react';
+ import { FC, memo, useState, useCallback } from 'react';

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);
- const handleIncrement = () => {
+ const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
- };
+ }, []);
  // handleIncrement は他に依存がないので dependencies は `[]` (空配列)

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      <MyComponent />
    </div>
  );
};

₍ ᐢ. ̫ .ᐢ ₎ yoshi!

props の場合 useCallback, useMemo でメモ化するだけではダメ

useCallback, useMemo でメモ化された prosp を渡しても、渡されるコンポーネント自体がメモ化されてないと親コンポーネントの再レンダリング時に再描画されてしまう

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  const handleDecrement = useCallback(() => {
    setCount((prevCount) => prevCount + -1);
  }, []);

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      <Button onClick={handleDecrement}>decrement</Button>
      <MyComponent />
    </div>
  );
};

decrement ボタンのコンポーネントuseCallback でメモ化された handleDecrement を受け取るがコンポーネントがメモ化されてないので <Counter> の state が変化した際に親コンポーネントと一緒に再レンダリングされてしまう

親から props を渡されるコンポーネントのメモ化

props を渡されるコンポーネントReact.memo でメモ化した上で、prosp に含まれるオブジェクトが useCallback, useMemo でメモ化されている必要がある


依存関係のない子コンポーネント

React.memo でメモ化すれば再レンダリングされない

// 親と依存関係のないコンポーネント
const MyComponent: FC = () => {
  return <div>MyComponent</div>;
};
+ // メモ化
+ const MemoMyComponent = memo(MyComponent);

// メインのコンポーネント
export const Counter: FC = () => {
  const [count, setCount] = useState<number>(0);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
-     <MyComponent />
+     <MemoMyComponent />
    </div>
  );
};

依存関係がないのでメモ化してしまえば当然、親コンポーネントの再描画されても再描画されなくなる
ただしメモ化にはコンポーネントに変化がないか計算する処理が含まれるので、必ずしもパフォーマンスが向上するわけではない

children として描画される依存関係のないコンポーネントは再レンダリングされない

ラッパーコンポーネントが増えてしまうが state を持つコンポーネント内で children として依存関係のないコンポーネントを描画するようにすれば、state が更新されても childrenReact.memo を使わなくても再描画されなくなる

// 親と依存関係のないコンポーネント
const MyComponent: FC = () => {
  return <div>MyComponent</div>;
};

// メインのコンポーネント
type CounterProps = {
  children: React.ReactNode;
};
const Counter: FC<CounterProps> = ({ children }) => {
  const [count, setCount] = useState<number>(0);

  const handleIncrement = useCallback(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  return (
    <div>
      <h1>Counter</h1>
      <span>{count}</span>
      <MemoButton onClick={handleIncrement}>increment</MemoButton>
      {children}
    </div>
  );
};

// ラッパーコンポーネント
export const CounterContainer: FC = () => {
  return (
    <Counter>
      <MyComponent />
    </Counter>
  );
}

Counter コンポーネントと依存関係のない <MyComponent> はメモ化していないが、<Counter> が再描画される際にも再描画されることがない。おそらくラッパーである CounterContainer で呼び出されているのでこちらに紐付いて (CounterContainer の子という扱い?) いて、CounterContainer が再描画される際にしか再描画されないのではと思われる

所感

とりあえず useCallback, useMemo しておくか〜という感覚だったけど、props として渡す場合は子コンポーネントがメモ化されてないと抑制されるのは関数の再生成だけだったと改めて認識できた
Twitter で見かけた children を使う方法は知らなかったので、依存関係のないコンポーネントを内部に含めたいコンポーネントを作る際に活用したいと思いました

今回も長くなっちゃった。
キャプチャをたくさん撮ったけど Mac デフォルトだと mov なのでいい感じに gif でキャプれるアプリ知りたい…
おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]

Vite + React (TypeScript) のプロジェクトに ESLint と Prettier を導入する。

前回までのあらすじ

Vite で React (TypeScript) のプロジェクトを作ってパスエイリアスの設定をいい感じにしました
今回はいつもの ESLint と Prettier を導入します。(ホントいつもの…)
結論から言えば VIte だからという事は特になく、create-react-app で作成したプロジェクトと大差ありませんでした

ESLint

パッケージのインストール

$ npm i -D eslint eslint-plugin-react-hooks

React のドキュメントに Hooks の呼び出し場所をコンポーネントとカスタムフック内に強制できる eslint-plugin-react-hooks について書かれていたので合わせて導入しました

ESLint の設定を作成

CLI で作成します

$ npx eslint --init
✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · react
✔ Does your project use TypeScript? · Yes
✔ Where does your code run? · browser
✔ What format do you want your config file to be in? · JavaScript
The config that you've selected requires the following dependencies:

eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest
✔ Would you like to install them now with npm?   Yes
ESLint の module type を何にすべきか?
? What type of modules does your project use? (Use arrow keys)
❯ JavaScript modules (import/export)
  CommonJS (require/exports)
  None of these

ESLint でプロジェクトの使っている module タイプの指定があります。Vite で作成された tsconfig.jsontarget: "ESNext", "module": "ESNext" となっているので、JavaScript modules (ESM) か None of these か迷いました。

tsconfig の module と target
  • targetコンパイル時にどのバージョンの JavaScript で出力するか
  • module … 出力されるJavaScriptがどのようにモジュールを読み込むか

module
es2015, es2020, esnext 通称 esmodule と呼ばれるモジュール読み込みの解決方法です。フロントエンドで使われています。Node.jsは13.2.0でバックエンドでも同様にこのモジュール解決方法をサポートしましたが2020年現在は対応しているパッケージは少ないです。
cf. tsconfig.jsonを設定する | TypeScript入門『サバイバルTypeScript』

tsconfig の module の ESNextesmodule の方法だとあったので ESLInt の What type of modules does your project use? の質問は JavaScript modules (import/export) を選択しました。
少し自信がないので、間違っていたら指摘いただきたいです :pray:

eslint-plugin-react-hooks の設定を追加

.eslintrc.js

module.exports = {
  "plugins": [
    // ...
+   "react-hooks"
  ],
  "rules": {
    // ...
+   "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks
+   "react-hooks/exhaustive-deps": "warn" // Checks effect dependencies
  }
}

import react from 'React' がない場合もエラーにならないルールを追加

React 17 から不要になっているが、ESLint がエラーになってしまうので

.eslintrc.js

module.exports = {
  // ...
  rules: {
    // ...
+   'react/react-in-jsx-scope': 'off',
  }
}

後はお好みでルールを追加すれば OK

Prettier

こちらも特に Vite だからという違いはなさそうです

$ npm i -D prettier eslint-config-prettier
# 設定ファイルの作成
$ touch .prettierrc.json

フォーマットの設定

.prettierrc.json

{
  "trailingComma": "all",
  "tabWidth": 2,
  "printWidth": 80,
  "singleQuote": true,
  "jsxSingleQuote": true,
  "arrowParens": "always",
  "bracketSpacing": true,
  "jsxBracketSameLine": false,
  "semi": true,
  "endOfLine": "lf"
}

※ お好みで

ESLint と連携

.eslintrc.js

{
  "extends": [
    //...
+   "prettier"
  ]
}

ESLint と Prettier 用の npm scripts を作成

package.json

{
  // ...
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
+   "lint": "eslint --ext .js,.jsx,.ts,.tsx src/",
+   "lint:fix": "npm run lint -- --fix",
+   "format": "npm run lint:fix && prettier --write 'src/**/*.{js,jsx,ts,tsx}'"
  },

使ってない import を自動で削除する

eslint-plugin-unused-imports

$ npm i -D eslint-plugin-unused-imports

.eslintrc.js

{
  "plugins": [
    "react",
    "@typescript-eslint",
+   "unused-imports"
  ],
  "rules": {
+   "@typescript-eslint/no-unused-vars": "off", // or "no-unused-vars": "off",
+   "unused-imports/no-unused-imports": "error",
+   "unused-imports/no-unused-vars": [
+     "warn",
+     { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" }
+   ]
  }
}

import の順番を自動でいい感じにする

eslint-plugin-import

$ npm i -D eslint-plugin-import

.eslintrc.js

{
  "plugins": [
    "react",
    "@typescript-eslint",
    "unused-imports",
+   "import"
  ],
  "rules": {
    // … 
    // import の順番の設定
+   "import/order": []
  }
}

順番の指定方法は eslint-plugin-import の Wikiを参考に下記のようにしてみました。

{
  rules: {
    'import/order': [
      'error',
      {
        // グループの順番
        groups: [
          'builtin',
          'external',
          'internal',
          ['parent', 'sibling'],
          'object',
          'type',
          'index',
        ],
        // グループ感に空行追加しない
        'newlines-between': 'never',
        pathGroupsExcludedImportTypes: ['builtin'],
        // ABC 順。大文字小文字を区別しない
        alphabetize: { order: 'asc', caseInsensitive: true },
        pathGroups: [
          // react 関連を先頭に
          {
            pattern: 'react**',
            group: 'external',
            position: 'before',
          },
          {
            pattern: '{@/libs/**,@/features/**,@/app/**}',
            group: 'internal',
            position: 'before',
          },
          {
            pattern: '{@/components/**,@/pages/**}',
            group: 'internal',
            position: 'before',
          },
          // css modules は一番最後にする
          {
            pattern: './**.module.css',
            group: 'index',
            position: 'after',
          },
        ],
      },
    ]
  }
}

所感

ESLInt, Prettier 共に Vite だからという違いは特にありませんでした。
新しくプロジェクト作る度にこの辺り調べてやり直してる気がする…

今回はやっている中で npm install の際に --save-exact オプションを使うと ^10.0.0 のような 10.0.0 以上ではなく 10.0.0 とインスールするバージョンが完全に固定できるということを知りることができました! 毎回やってるけど何かしら発見あるからよし!(永遠の初心者)

おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]

O'Reillyの読んだらブルーベリー本買うんだ…

もうネタがプロジェクトキレイキレイしか思いつかねぇ…

Vite + React で path alias を使いたい!

前回までのあらすじ

npm create vite コマンドでサクッと Vite + React (TypeScript) のプロジェクトが作れました!
SPA はファイルがたくさんになるので import の際に ../../../ HELL になりがちです。これを回避するためにパスエイリアス(path alias)を設定しておきたいお気持ちに溢れます。

結論: vite-tsconfig-paths を使えば楽ちん

vite-tsconfig-pathsを使えば tsconfig.json にパスエイリアス設定を作成するだけで済む。

$ npm i -D vite-tsconfig-paths

vite.config.ts

import { defineConfig } from 'vite'
+ import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
+ plugins: [tsconfigPaths()],
})

これで tsconfig.json に設定したパスエイリアスの設定が効くようになる


下記経緯

Vite の path alias の設定方法

cf. resolve.alias

  • vite.config.tsresolve.alias の項目を作成してパスエイリアスの設定を作成する
    • resolve.alias: Record<string, string> | Array<{ find: string | RegExp, replacement: string, customResolver?: ResolverFunction | ResolverObject }>
  • 設定は内部的に使われている @rollup/plugin-alias の書き方を参照する
  • TypeScript なプロジェクトの場合は tsconfig.json にも同様の設定をする必要がある

e.g. @/src/~/public/ にアクセスできるようにする

1. vite.config.tsresolve.alias を作成

vite.config.ts

+ import path from 'path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
+ resolve: {
+   alias: {
+     '@/': `${__dirname}/src/`,
+     '~/': `${__dirname}/public/`
+   },
+ },
  plugins: [react()],
});

[{ find: 'エイリアス', replacement: '変換するパス' }] で書くなら下記のような感じ

export default defineConfig({
+ resolve: {
+   alias: [
+     { find: '@/', replacement: `${__dirname}/src/` },
+     { find: '~/', replacement: `${__dirname}/public/`},
+   ],
+ },
  plugins: [react()],
});
resolve.alias は変換するパスが / で終わってないと正しくエイリアスとして動作しない。
  • 🙅 NG `${__dirname}/src`, path.join(__dirname, 'src')
  • 🙆 OK `${__dirname}/src/`, path.join(__dirname, 'src/')

最後が / で終わってないと次のようなエラーになる
Failed to resolve import "@/App.module.css" from "src/App.tsx". Does the file exist?
パス末の / が原因だと分からなくて時間とかした… (ᐡ •̥ ̫ •̥ ᐡ)

cf. <2022/02更新>vite+TypeScriptでalias pathを~に設定する

2. __dirname が TS のエラー Cannot find name '__dirname'.ts(2304) になるので修正する

node の型が入ってないのが原因。Vite で作ったプロジェクトにはデフォルトでは含まれてないっぽい。

$ npm i -D @types/node

tsconfig.json

{
  "compilerOptions": {
+   "types": ["node"],
    // …

cf. node.js - TSC cannot find name of Global Object - Stack Overflow

3. tsconfig.json にパスエイリアスの設定を追加

tsconfig.json

{
  "compilerOptions": {
    // …
+   "paths": {
+     "@/*": ["src/*"],
+     "~/*": ["public/*"]
+   },
    // …

これで tsx 内の import を @/~/ に変更して npm run dev をして問題なく動作していれば OK ₍ ᐢ. ̫ .ᐢ ₎👌

🌟 vite-tsconfig-paths を使う方法 (個人的オススメ!)

vite-tsconfig-pathsを使えば vite.config.ts に alias の設定を書く必要がなくなりパスエイリアスの設定を tsconfig.json に一元化できる! (@types/node のインストールも不要になる)

インストール

$ npm i -D vite-tsconfig-paths

vite.config.ts

import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
+ import tsconfigPaths from 'vite-tsconfig-paths';

// https://vitejs.dev/config/
export default defineConfig({
  server: {
    open: true,
  },
- plugins: [react()],
+ plugins: [react(), tsconfigPaths()],
});

tsconfig.json

{
  "compilerOptions": {
    // ...
+   "paths": {
+     "@/*": ["src/*"],
+     "~/*": ["public/*"]
+   },
    // …

₍ᐢ •̥ ̫ •̥ ᐢ₎‪ できた!!!!
TSのエラーと戦う必要もなく、パスエイリアスの設定も一元管理できるので vite-tsconfig-paths プラグイン使ってしまうのが良さそうです。


VSCode でパスエイリアスの補完が効くようにする

いつもの。cf. GitHub - KiKiKi-KiKi/ts-react-app: React with TypeScript app template
path-autocomplete.pathMappings に補完させたいパスエイリアスを設定できる。

$ mkdir .vscode
$ touch .vscode/settings.json

今回は @//src 配下のファイルを、~//public 配下のファイルを補完させたいので下記のように設定を追加すればOK

.vscode/settings.json

{
  "path-autocomplete.pathMappings": {
    "@": "${folder}/src",
    "~": "${folder}/public"
  }
}

VSCode@/, ~/ と打った時にパスの保管がサジェストされるようになっていればOK

cf.

所感

随分大きなエントリーになってしまったけど、プラグインを使えば簡単に path alias が設定できました !! チョロいから Vite 好きになってきちゃったな… ぽわぽわ…
おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]

全然関係ないけど、SPY×FAMILY めちゃ好〜!! 原作の表紙がミッドセンチュリーなチェアで統一されてるのもとても良… 原作買うか!? (本棚の空き場所無いぞ…)