useEffectの第2引数には何を指定すればよいのか

https://ja.reactjs.org/docs/hooks-reference.html#useeffect

useEffectの第2引数として配列を渡すことで配列に指定した値それぞれに変更があった場合のみuseEffect内の関数が実行されます。

useEffect内でなにか関数を実行する際、第2引数として関数も渡すことがあると思いますが、これが正しいのかどうか分からず調べてみました。

例えばAPIから取得したデータをstateにセットするみたいなコードがあったとします(コードは適当です)

const Component = () => {
  const [count, setCount] = useState(0);
  const { response } = useAPI();
  
  useEffect(() => {
    setCount(response.length);
  }, [response, setCount]);   // ここでsetCountって必要?
}

このset関数はuseEffectの第2引数に含める必要があるのでしょうか?結論から言うと↑のコードでは setCount はセットする必要はありません。

useStateのドキュメントに以下のような記述があり、再レンダリングされた場合もset関数は同一性が担保されておりuseEffectの依存リスト(第2引数)に含めても値が変わることがないので入れる必要がありません。

同様にuseReducerも同じような記述がありdispatchも入れる必要がありません。

通常の関数は再レンダリング時に同一性がない

以下のように書くと、関数で実行する内容は変わらないにもかかわらず再レンダリングされるたびにuseEffectが実行されします。

const Component = () => {
  const [count, setCount] = useState(0);

  const hello = () => {
    console.log('hello!');
  }
  
  useEffect(() => {
    hello();
  }, [hello]);
}

これは再レンダリングのたびに関数が再生成されるため、変更があったかどうかを判断するObject.is()はfalseとなりuseEffectが実行されます。↓のイメージです。

Object.is(() => null, () => null);  // => false

上記の場合どうするのがいいのかというと、A Complete Guide to useEffectによれば、propsやstateに依存しない関数の場合はコンポーネントの外で関数を定義し、依存する場合はuseEffect内で関数を定義するのがよいとのこと。

// コンポーネントの外で定義
const hello = () => {
  console.log('hello!');
}

const Component = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    hello();
  }, []);
}
const Component = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // useEffect内で定義
    const hello = () => {
      console.log(data);
    }

    hello();
  }, [data]);
}

useCallbackを使って関数自体をメモ化して再レンダリングされた場合でも同一のものを返すということもできますが、公式ドキュメントには最終手段と書いているので、上記2つで書くことができない場合に使うとよさそうです。

const Component = () => {
  const [count, setCount] = useState(0);

  const hello = useCallback() => {
    console.log(data);
  }, [])

  useEffect(() => {
    hello();
  }, [hello]);
}

ESLintのeslint-plugin-react-hooksプラグイン

ESLintのeslint-plugin-react-hooksプラグインを利用しreact-hooks/exhaustive-depsのルールをONにすることで、useEffect, useCallbackの第2引数を自動でチェックすることができます。

上記のuseStateのset関数やuseReducerのdispatch関数、useRefのrefはあらかじめ除外されており、チェックされないので含める必要がありません。また、通常の関数についても再レンダリングのたびにuseEffectが実行される場合はlintで教えてくれます。

カスタムフックに処理を出している場合に注意が必要

カスタムフック内で関数を定義し、それをコンポーネント側で利用するときはこのようなコードになるかと思います。

const useHello = () => {
  const hello = (count) => {
    console.log('hello! count', count);
  }
  return { hello };
}

const Component = () => {
  const [count, setCount] = useState(0);
  const { hello } = useHello();

  useEffect(() => {
    hello(count);
  }, [count, hello]);
}

このコンポーネントが再レンダリングされたとき、countが変更されているいないに関わらずuseEffectが実行されます。この場合はESLintのreact-hooks/exhaustive-depsでも検知することはできないため、カスタムフックの実装時に関数は同一性が担保されるように実装する必要があります。

関数の実行結果を第2引数に指定してもよいか

あまり書かないでしょうがこんなコードです。

const neededCountUp = () => {
  return true;
};

const Component = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(prev => prev + 1);
  }, [neededCountUp()]);
}

これも結論から言うと動作はしますが、当然再レンダリング時に前回の情報とのObject.is()で変更があったかを判断するため、↑のコードでは関数の返り値として常にtrueが返ってきており2回目以降のレンダリング時にはuseEffectは実行されしません。再レンダリング時に関数が毎回実行され返り値からuseEffectを実行させるか判断するという挙動となりあまり効率は良くないコードかもしれません。

ちなみに↑のようなコードではESLintoのreact-hooks/exhaustive-depsではチェックにかかり、関数実行の返り値の別の変数にいれましょうというメッセージが出ます。

JestのmoduleNameMapperで画像のfile mockができなくてハマった

const { pathsToModuleNameMapper } = require('ts-jest/utils');
const { compilerOptions } = require('./tsconfig.json');

module.exports = {
  moduleNameMapper: pathsToModuleNameMapper(
    compilerOptions.paths,
    { prefix: '<rootDir>/' }
  )
}

tsconfig.jsonのcompilerOptionsにpaths設定をしているので、pathsToModuleNameMapperを使ってみたいな感じで書いていた。

svgファイルをimportするコードがあったので https://jestjs.io/ja/docs/webpack を参考にfileMockさせようと思いこんな感じで書いた。

const { pathsToModuleNameMapper } = require('ts-jest/utils');
const { compilerOptions } = require('./tsconfig.json');

module.exports = {
  moduleNameMapper: pathsToModuleNameMapper(
    {
      "\\.(jpg|jpeg|png|gif|svg)$": ["./src/tests/mocks/fileMock.js"],
      ...compilerOptions.paths,
    },
    { prefix: '<rootDir>/' }
  )
}
module.exports = 'test-file-stub';

するといっこうにfileMockが効かない。。。
2時間くらい格闘したけど、結論pathsToModuleNameMapperの中にいれていたのが間違いで、外に設定すれば動いた。

const { pathsToModuleNameMapper } = require('ts-jest/utils');
const { compilerOptions } = require('./tsconfig.json');

module.exports = {
  moduleNameMapper: {
    "\\.(jpg|jpeg|png|gif|svg)$": ["<rootDir>/src/tests/mocks/fileMock.js"],
    ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
  },
}

pathsToModuleNameMapperのソースコードを見てみるといろんなエスケープ処理が入っていて、tsconfig.jsonのcompilerOptions.paths用に作られていた。(そりゃそうだ)

pathsToModuleNameMapperにいれると↓みたいになっててそりゃ一致しないよねってことでした。

{
  '^\\\\\\.\\(jpg\\|jpeg\\|png\\|gif\\|svg\\)\\$$': '<rootDir>/./src/tests/mocks/fileMock.js',
}

styled-componentsのThemeProviderをつかってReactのスタイルを管理する

Reactのスタイル管理、どうしていますか。

いろいろな方法があると思いますが、大規模なReactでのフロントエンド開発においてCSSをどう管理するかとても悩みました。私の所属しているチームでは、styled-componentsのThemeProviderを使ってスタイルを管理することにしましたので紹介します。

コード例

例えば簡単なButtonコンポーネントを実装し、propsで背景色を変えたい場面があったとします。

import React from "react";
import styled from "styled-components";

const StyledButton = styled.button`
  background-color: ${props => (props.color === "primary" ? "blue" : "gray")};
`;

export default function Button({ color, children }) {
  // わかりやすいようにpropsを分けて渡しています
  return <StyledButton color={color}>{children}</StyledButton>;
}

この実装だとcolor propsの種類が増えたときに分岐の実装が増えていくため、objectに設定を切り出して書いたりします。

const backgroundColor = {
  default: "gray",
  primary: "blue",
  warning: "orange"
}

const StyledButton = styled.button`
  background-color: ${props => backgroundColor[props.color]};
`;

上の実装でだいぶ変更には強くなります。ただ、もしcolorのprimaryやwarning設定をある一部分だけ違う色に変えたいという場面があったとします。Buttonコンポーネント内で色を固定してしまっているため、propsを追加するか、styled-componentsのcss propsを渡すか、もう一つButtonをラップしたコンポーネントを作るなどの方法を取らねばなりません。

そういった場合にThemeProviderを使って実装すると便利なことがあります。

import React from "react";
import { ThemeProvider } from "styled-components";
import Button from "./Button";

const theme = {
  button: {
    backgroundColor: {
      default: "gray",
      primary: "blue",
      warning: "orange"
    }
  }
};

export default function ButtonGroup() {
  return (
    <ThemeProvider theme={theme}>
      <Button color="default">Default</Button>
      <Button color="primary">Primary</Button>
      <Button color="warning">Warning</Button>
    </ThemeProvider>
  );
}
import React from "react";
import styled from "styled-components";

// ThemeProvider内のコンポーネントはpropsにthemeがセットされている
const StyledButton = styled.button`
  background-color: ${props => props.theme.button.backgroundColor[props.color]};
`;

export default function Button(props) {
  return <StyledButton {...props} />;
}

Buttonコンポーネントを使う側(ButtonGroup.js)でThemeProviderを使い theme propsで色を設定しているobjectを渡します。ThemeProviderで囲まれた全コンポーネントには theme というpropsが自動で追加されており、 props.theme にはThemeProviderで渡したobjectが入っています。

これでコンポーネントを利用する側からスタイルをカスタムしやすくなりました。同時にButtonコンポーネントではCSSとしてThemeProviderからの値をセットするだけで、背景色自体の管理はしなくなっています。

以下のようにobjectを書き換えれば、一部分だけ設定を変更することができます。

import React from "react";
import { ThemeProvider } from "styled-components";
import deepmerge from "deepmerge"
import Button from "./Button";

const defaultTheme = {
  button: {
    backgroundColor: {
      default: "gray",
      primary: "blue",
      warning: "orange"
    }
  }
};
const customTheme = deepmerge(defaultTheme, {
  button: {
    color: {
      primary: "green",
      warning: "red"
    }
  }
});

export default function ButtonGroup() {
  return (
    <ThemeProvider theme={defaultTheme}>
      <div>
        <Button color="default">Default</Button>
        <Button color="primary">Primary</Button>
        <Button color="warning">Warning</Button>
      </div>
      <div>
        <ThemeProvider theme={customTheme}>
          <Button color="default">Default</Button>
          <Button color="primary">Primary</Button>
          <Button color="warning">Warning</Button>
        </ThemeProvider>
      </div>
    </ThemeProvider>
  );
}

ThemeProvider自体はReactのContextの仕組みを使っているので、一番近いところで渡されているThemeProviderの値がセットされるようになっています。カスタムしたい場合は、デフォルトのテーマ設定objectからマージして書き換えたい部分のみ上書きするようにします。

これでcustomThemeを渡した方のみカスタムした背景色が当たるようになりました。

試してみたソースコードはこちらに置いています。 https://codesandbox.io/embed/strange-http-1uhlf?fontsize=14&hidenavigation=1&module=%2Fsrc%2FApp.js&theme=dark

ThemeProviderを利用するメリット

コンポーネントとスタイル管理を切り離して管理できる

上記の例ではButtonコンポーネント内で背景色のスタイル管理をしなくなりました。つまりスタイル変更のためだけにButtonコンポーネントを修正する必要がなくなるということです。背景色だけでなくサイズやテキストの色などもThemeProviderで管理するようにすることですべてコンポーネントを利用する側でスタイル管理ができるようになります。

ただStorybookでコンポーネントカタログを作るときなど、ThemeProviderを利用せずコンポーネントのみで表示させたい場合、 props.theme がセットされないためundefinedになりJavaScriptエラーが発生してしまいます。その場合ReactのdefaultPropsを利用するとよいと思います。

import React from "react";
import styled from "styled-components";
import { defaultTheme } from "./defaultTheme"

const StyledButton = styled.button`
  background-color: ${props => props.theme.button.backgroundColor[props.color]};
`;
StyledButton.defaultProps = {theme: defaultTheme}

export default function Button({ color, children }) {
  return <StyledButton color={color}>{children}</StyledButton>;
}

これでThemeProviderを利用しない場合でもdefaultPropsによりデフォルトのテーマ設定で props.theme が渡された状態になります。

CSSの詳細度の戦いになりにくい

コンポーネント内でCSSを設定していた場合、そのCSSを上書きするためには利用するコンポーネント側でCSSの詳細度を考えながら書かなければなりません。ときには !important を使わないといけない悲しい場面もあるかもしれません。

ThemeProviderを利用した場合、セットするthemeはJSのobjectなのでobject自体を上書きして渡してしまえば詳細度の戦いになりません。styled-componentsの仕様として、CSSの値にundefinedを渡せばプロパティ自体出力されなくなるので、無駄なCSSが出力されないというメリットもあります。

TypeScriptでも書ける

https://github.com/styled-components/styled-components/issues/1589#issuecomment-456641381 を参考にtheme props型をつけることができます。@types/styled-components の実装でDefaultThemeのinterfaceを拡張するとthemeに型をつけることができるようになっています。テーマ管理しているobjectのtypeofをとり以下のようにDefaultTheme interfaceに適用します。

import { defaultTheme } from "path/to/defaultTheme";

type ITheme = typeof defaultTheme;

declare module "styled-components" {
  interface DefaultTheme extends ITheme {}
}

少しロジックが入ったりする場合は、styled-componentsのテンプレート文字列全体を関数にして最後にcssを使って文字列を返すような実装もできます。

import React from "react";
import styled, { css } from "styled-components";
import { defaultTheme } from "./defaultTheme"

type Props = {
  color?: "default" | "primary" | "warning";
  disabled?: boolean;
  children: React.ReactNode;
}

const StyledButton = styled.button<Props>`
  ${({theme, color, disabled}) => {
    let backgroundColor = disabled ? "darkgray" : theme.button.backgroundColor[color];
    return css`
      background-color: ${backgroundColor};
    `
  }}
`;
StyledButton.defaultProps = {theme: defaultTheme}


export default function Button(props: Props): React.ReactElement {
  return <StyledButton{...props} >;
}

最後に

デフォルトのテーマ設定objectを別のobjectに切り替えるだけで、ユーザーごとのテーマ切り替えやダークテーマの対応がしやすかったりするメリットもあると思います。

直感的なCSSファイルでの実装ではないため、デザイナーがコードまで修正しているチームの場合修正しにくいデメリットはあると思いますが、エンジニアが管理しているチームでは管理しやすくなると思いますのでぜひ試してみてください。

参照リンク