RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

Rules of ReactをベースにReactとは一体なんなのかを改めて考えてみる

はじめに

Rules of Reactという安全で効果的なReactアプリケーションを記述するためのルールがReact公式から公開されました。

Writing idiomatic React code can help you write well organized, safe, and composable applications. These properties make your app more resilient to changes and makes it easier to work with other developers, libraries, and tools.

These rules are known as the Rules of React.


https://github.com/reactjs/react.dev/blob/40d73490733a1665596eee8b547278231db3b8e3/src/content/reference/rules/index.mdより引用

Rules of Reactに反することにより、バグの原因となってしまったり、理解することが難しいコードになる恐れがあると言及されています。

They are rules – and not just guidelines – in the sense that if they are broken, your app likely has bugs. Your code also becomes unidiomatic and harder to understand and reason about.


https://github.com/reactjs/react.dev/blob/40d73490733a1665596eee8b547278231db3b8e3/src/content/reference/rules/index.mdより引用

調べてみて、このRules of Reactは「そもそもReactとは一体どのようなフレームワークなのか?」を理解する上でも有用なのではないかと思いました。

そこで、この記事ではRules of Reactを題材に、あらためてReactとはどのようなフレームワークなのかについて見ていきたいと思います。

想定読者

この記事はどちらかというとReactの初学者〜ある程度慣れてきた方を想定して記述しています。

抽象的な内容が結構多いので、もし具体的な内容にのみ興味があるという場合は、終盤の「実践編」の内容だけ参照ください。

注意

今後、リリースされる予定のReact v19ではuse()などの新しいAPIが導入される予定です。この記事はそれらのAPIについては考慮に入れず執筆していますが、今後、Reactにおける副作用の取り扱い方法にも変化が起きる可能性も考えられます。しかし、Reactの背景にある考えについてはそう大きくは変わらないと思います。

Components and Hooks must be pure (コンポーネントとフックは純粋でなければならない)

Rules of Reactに関するドキュメントの一つにComponents and Hooks must be pure (コンポーネントとフックは純粋であるべき)というページがあります

github.com

直訳すると「コンポーネントとフックは純粋でなければならない」というような感じではないかと思います。では「純粋」とは一体何を指しているのでしょうか? これはRules of Reactを理解する上で重要な考えではないかと思うため、まずは純粋関数というものについて見ていきます。

純粋関数と参照透過性について

以下のような関数があったとします。

const plus = (a, b) => a + b;
const double = (x) => 2 * x;
const inc = (x) => 1 + x;

これらの関数は、引数のみに依存し、副作用が存在しないという共通した特徴を持ちます。こういった関数を純粋関数と呼びます。

まず、純粋関数の特徴である「引数のみに依存する」という点についてはわかりやすいのではないかと思います。上記の関数はすべて引数以外の要素には全く依存をしていません。

では、もう一方の「副作用が存在しない」とはどういうことでしょうか? 大雑把に説明すると、外部の状態を変更する処理は副作用があると説明できるかと思います。具体的には、以下のような処理は副作用であると思います。

  • グローバル変数やパッケージローカルな変数の上書き
  • 乱数の生成
  • localStorageへのデータの保存
  • fetch()によるAPIの呼び出し
  • Consoleへの出力

純粋関数は引数以外の要素に依存しないため、ある純粋関数に同じ引数の組み合わせが与えられた場合、常に同じ結果を返すという特徴があります (このような性質を参照透過性と呼びます)

次は逆に純粋ではない関数の例を見てみます。

const formatDate = () => {
  const date = new Date();
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${y}/${m}/${d}`;
};

このformatDate関数はなぜ純粋関数ではないのでしょうか?以下の行がポイントです。

  const date = new Date();

new Date()は呼び出しのたびに結果が変わる処理(=参照透過性がなく、純粋ではない)であり、このnew Date()を呼んでいるformatDateも同様に純粋ではなくなります。

それでは、このformatDateを純粋関数にするためにはどうしたらいいでしょうか?この場合は、dateformatDate関数内で生成するのではなく、引数として受け取るようにすれば純粋関数にできます。

// フォーマット対象の日付を引数で受け取る
const formatDate = (date) => {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${y}/${m}/${d}`;
};

このformatDateの例ではnew Date()を例に説明しましたが、以下のように関数の外の変数を変更するような関数も純粋関数ではありません。

// 関数の外の変数に依存している
let queue = [];
const enqueue = (x) => {
  queue.push(x);
};
const dequeue = () => queue.shift();

この場合もキューを引数として受け取るようにすると、副作用を排除することができます。

const enqueue = (queue, x) => {
  return [...queue, x];
};
const dequeue = (queue) => {
  const [head, ...rest] = queue;
  return [head, rest];
};

const [item, queue] = dequeue(enqueue(enqueue(enqueue([], 1), 2), 3));
item;  // => 1
queue; // => [2, 3]

また、JavaScriptにおいてよく見かける、引数に与えられたオブジェクトを直接変更するような関数も純粋関数ではありません。

const completeTask = (task) => {
  task.status = 'completed';
};

このようなケースでは、以下のように新しいオブジェクトを生成して返却してあげれば、副作用を排除することができます。

const completeTask = (task) => ({
  ...task,
  status: 'completed'
});

それ以外にも、フロントエンドにおいてよく見かけるケースが多いと思われるlocalStoragefetchなどのAPIに依存した関数も純粋関数ではありません。

// localStorageという引数以外の外部のリソースに依存している
const readItems = (id) => {
  const data = localStorage.getItem(id);
  return parseItems(data);
};

// fetchによってHTTPリクエストを送信している
const getUserByID = (id) => {
  const res = await fetch(buildUserByIDURL(id));
  return await res.json();
};

ただし、上記のようにlocalStorageへのデータの永続化や復元、fetch()によるHTTPリクエストの送信などはアプリケーションを構成する上で非常に重要な要素です。副作用そのものは便利なアプリケーションを開発するためには必要不可欠なものです。

副作用の存在をきちんと認識し、むやみな乱用を避けたり、きちんと分離することなどが重要ではないかと思います。

純粋関数のメリット

純粋関数について紹介しましたが、具体的にこの純粋関数を使うメリットとは何でしょうか?以下のような点が考えられるでしょう。

1. 結果が予測しやすい

引数以外の要素に依存せず外部要因によって計算結果が変わらないため、計算結果を予測しやすいケースが多いと思います。

また、純粋関数には外部依存がないため、大抵の場合、テストダブル(モックやスタブ、フェイクなど)を用意する必要がなくユニットテストを容易に記述することができます。

2. コードの再利用がしやすい

外部の状態やリソースなど、特定の外部コンテキストへの依存が少なく、引数以外の要素には依存しないため、コードを変更することなく様々な箇所で再利用がしやすいです。

3. 計算結果を安全にキャッシュできる

純粋関数には副作用がなく、同じ引数で呼ばれれば必ず同じ結果を返す性質があるので、関数の計算結果を安全にキャッシュすることができます。

具体例として、メモ化と呼ばれる関数の最適化手法があるため、紹介します。

ja.wikipedia.org

以下はメモ化の単純化した例です。memoizeは指定された関数fnをメモ化します。

// 指定された関数fnをメモ化します
function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn();
    cache.set(key, result);
    return result;
  };
}

このmemoize関数は、以下のように使用します。

const add = (a, b) => a + b;

// addのメモ化バージョン
const memoizedAdd = memoize(add);

memoizedAdd(1, 2); // => 3
memoizedAdd(1, 2); // => 3 (引数の組み合わせが同じため、add(1, 2)を呼ばずにキャッシュされた結果が返却されます)
memoizedAdd(2, 3); // => 5 (引数の組み合わせが異なるため、add(2, 3)が呼ばれます)

純粋関数は引数以外の要素には依存せず、副作用も存在しないため、安全にメモ化を行うことができます。

上記の例は説明のためだいぶ簡略化されています。もし実際にメモ化を使いたい場面が出てきた際は、以下のような本格的なライブラリを使うことをお勧めします。

メモ化について紹介しましたが、ReactにはReact.memo()useMemo()などのAPIがあります。これらはコンポーネントなどに対してメモ化を適用するためのAPIであると考えられるかと思います。

Reactコンポーネントを純粋関数として実装する

それでは、具体例の一つとしてこの純粋関数をReactのコンポーネントに当てはめて考えてみます。

Reactコンポーネントは、StateやEffectなどを持たなければ、propsを受け取りVNodeを返す純粋関数として実装することができます。

github.com

以下のコンポーネントはprops.usersにのみ依存しており、props.usersが同じであれば、何度呼んでも同じレンダリング結果が得られます。

function UserList(props) {
  return (
    <ul>
      {
        props.users.map((x) => (
          <li key={x.id}>{x.name}</li>
        ))
      }
    </ul>
  );
}

副作用を持つReactコンポーネントについて

それでは逆にReactにおいて副作用を扱いたい場合、つまり純粋ではないコンポーネントを実装したい場合はどうすれば良いのでしょうか?こういった場合にEffectやイベントハンドラーなどを利用します。

function UserList() {
  const [isLoading, setIsLoading] = useState(false);
  const [users, setUsers] = useState([]);
  // 副作用を起こすためにEffectを使う
  useEffect(() => {
    const ac = new AbortController();
    setIsLoading(true);
    fetch('https://api.example.com/users', { signal: ac.signal })
      .then((res) => res.json())
      .then((users) => setUsers(users))
      .finally(() => setIsLoading(false));
    return () => ac.abort();
  }, []);
  
  // ... 省略 ...
}

Reactと参照透過性の関係について

Reactのコンポーネントの実態はprops, State, またはContextを受け取りVNodeを返す関数であり、入力として受け取ったprops, State, またはContextが同じであれば同じレンダリング結果が得られます。このようにReactではフレームワークレベルで参照透過性が意識されていることが伺えます。これによりUIのレンダリング結果を予測しやすくしたり、デバッグを行いやすくなることなどが期待されます。

Rules of Reactの各ルールについて

github.com

前置きが長くなりましたが、上記までの純粋関数や参照透過性などに関する内容などを踏まえて、あらためてRules of Reactで紹介されているルールについていくつか見ていきます。

Side effects must run outside of render (副作用はレンダリングフェーズの外で実行しなければならない)

これは副作用はレンダリングフェーズの外で行うべきであるというルールで、具体的にはイベントハンドラーやEffect (useEffect)によって副作用は実行されるべきです。

例えば、コンポーネント内において、コンポーネントの外の変数を変更しているようなケースはこのルールに違反します。

let id = 0;
function Message(props) {
  id = id + 1; // <= ここ
  return (
    <div>
      Message #{id}: {props.message}
    </div>
  );
}

上記コンポーネントにおいて問題なのは以下の箇所です。

let id = 0;
function Message(props) {
  id = id + 1; // <= ここ
  // ... 省略 ...
}

ここではコンポーネントのレンダリングフェーズにおいて、コンポーネント外の変数が上書きされてしまっています。(副作用が発生している)

このように、コンポーネントのレンダリングフェーズにおいて副作用を発生させることはReactにおいては行うべきではありません。もし副作用を発生させたい場合は、Effectやイベントハンドラーなどを利用する必要があります。

このようにレンダリングフェーズにおいて副作用が発生しないことを前提とすることで、Reactでは優先度に応じて特定のコンポーネントだけレンダリングを中断したり再開したりする余地が生まれます。

Components and Hooks must be idempotent (コンポーネントとフックはべき等でなければならない)

直訳するとコンポーネントとフックはべき等でなければならないといった意味になるかと思います。

べき等であるとはなんでしょうか? 大雑把に言えば、ある処理を何回行っても同じ結果が得られる性質を指します。

glossary.cncf.io

純粋関数は同じ引数が与えられれば常に同じ結果を返すため、引数が一致している場合、純粋関数にはべき等性が担保されます。

ではこのルールについて、先ほど使ったコンポーネントを再掲して紹介します。

let id = 0;
function Message(props) {
  id = id + 1; // <= ここ
  return (
    <div>
      Message #{id}: {props.message}
    </div>
  );
}

上記のコンポーネントは、このComponents and Hooks must be idempotentルールに違反しています。どうしてかというと、このコンポーネントはidという外部の変数に依存しているので、例え同じ <Message message='foo' />という呼び出しを行った場合でも、実行の度にレンダリング結果が変わってしまいます (=べき等ではない)

Reactにおいては、あるコンポーネントに同じprops, State, またはContextが与えられれば、常に同じレンダリング結果を生成する(=べき等である)ことが期待されます。

このルールを守るためには、以下のような点を守る必要があります。

  • Effectなど、定められた方法以外で副作用を発生させない (Side effects must run outside of renderルールに準拠する)
  • useEffectなどの各種フックには正しいdepsを指定する

Props and state are immutable (Propsとstateは不変である)

このルールはpropsやStateを直接の変更を禁止しています。

例えば、以下のようにpropsオブジェクトを直接変更している場合はこのルールに違反しています。

function Double(props) {
  props.count *= 2;
  return <span>{props.count}</span>;
}

// 本来は以下のようにすべき
// function Double(props) {
//   return <span>{props.count * 2}</span>;
// }

また、上記のコードはレンダリングフェーズにおいて引数として渡されたpropsオブジェクトを直接変更している(=副作用を起こしている)ので、Side effects must run outside of renderにも違反しています。

Stateを更新する場合も、変数に代入するのではなく、useStateから返却されたsetter関数を使う必要があります。

let [isLoading, setIsLoading] = useState(false);
useEffect(() => {
  isLoading = true; // <= これは誤り
  // 以下のようにすべき
  // setIsLoading(true); 
}, []);

Return values and arguments to Hooks are immutable (フックの戻り値と引数は不変である)

フックに引数として渡されたオブジェクトや、フックが返却したオブジェクトを直接変更してはならないというルールです。なぜ変更してはいけないのかというと、そのフックに依存したコンポーネントや別のフックが意図せぬ振る舞いをしてしまう可能性があるためです。

フックに渡す引数についてはProps and state are immutableルールと同様に、直接の編集は避けるべきです。

例えば以下のようなフックがあったとします。

export function useFetch(url, options) {
  if (options.headers == null) options.headers = { 'content-type': 'application/json' }; // ここ
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null); 
  useEffect(() => {
    const ac = new AbortController();
    const signal = ac.signal;
    setIsLoading(true);
    fetch(url, options)
      .then((res) => res.json())
      .then((data) => setData(data))
      .catch((error) => setError(error))
      .finally(() => setIsLoading(false));
  }, [url, options]);
  return { isLoading, error, data };
}

このフックにおける、下記の箇所でReturn values and arguments to Hooks are immutableルールの違反があります。

export function useFetch(url, options) {
  if (options.headers == null) options.headers = { 'content-type': 'application/json' }; // ここ
  // ... 省略 ... 
}

この場合、引数のoptionsを直接編集するのではなく、新しいオブジェクトを生成すべきです。

if (options.headers == null) options = {
  ...options,
  headers: { 'content-type': 'application/json' }
};

Values are immutable after being passed to JSX (JSXに渡された値は不変である)

これは一見、想像しづらいかもしれないですが、以下のようなコードでこのルールの違反が発生しています。

function Layout(props) {
  const styles = { fontSize: '14px', width: '100%' };
  const header = <Header styles={styles} />;
  const main = (
    <Main styles={styles}>
      {props.children}
    </Main>
  );
  styles.fontSize = '12px'; // => ここ
  const footer = <Footer styles={styles} />;
  return (
    <>
      {header}
      {main}
      {footer}
    </>
  );
}

問題なのは以下の箇所で、stylesオブジェクトは<Header>のpropsとして渡されたあとに直接変更が加えられています。このような場合、意図せぬレンダリング結果を引き起こしてしまうことが考えられます。

  const styles = { fontSize: '14px', width: '100%' };
  const header = <Header styles={styles} />;
  // ... 省略 ...
  styles.fontSize = '12px'; // => ここ
  const footer = <Footer styles={styles} />;

この場合もReturn values and arguments to Hooks are immutableルールなどのケースと同様に、新しいオブジェクトを作成するとよいでしょう。

  const styles = { fontSize: '14px', width: '100%' };
  const header = <Header styles={styles} />;
  // ... 省略 ...
  const footerStyles = { ...styles, fontSize: '12px' }; // 新しいオブジェクトを作る
  const footer = <Footer styles={footerStyles} />;

なぜRules of Reactが重要か?

まず、Reactにおいてコンポーネントはどういった記述を避けるべきかがしっかりと定義されたことが挙げられるかと思います。Ruels of Reactに従っておくことでバグが発生しにくいコードを記述するのに役立ち、また今後のReactや周辺エコシステムのアップデートなどにも追従しやすくなることも期待されます。

また、Rules of Reactに従っておくことで、React公式によって開発されているReact Compilerによる最適化の恩恵を受けやすくなるメリットもあります。

github.com

React CompilerはReactで提供されているuseCallbackuseMemo, React.memoなどによる最適化の適用を自動化してくれます。ただし、このReact Compilerがきちんと動作するためには、ReactコンポーネントやフックがRules of Reactに従っていることが重要です。

React Compilerは実験的ツールであるため、現時点での導入はまだ早いとは思いますが、後述するeslint-plugin-react-compilerというESLintプラグインがあるため、まずはこちらの導入を検討すると良いです。

Reactとは何なのか?

Rules of Reactの一連のルールについての概要を確認しました。これらの一連のルールに共通する点として「副作用の存在をきちんと意識する」ということがReactにおいて重要なポイントなのではないかと思いました。

Reactが誕生した当初、人気のあったいくつかのフレームワーク (Angular.js v1など)は双方向バインディングという機能を備えていました。この機能はとても便利である一方、複雑化するとレンダリング結果が予測しづらくなりがちであるという課題がありました。Reactではフレームワークレベルで参照透過性を意識して設計されることにより、このレンダリング結果が予測しづらくなる課題を解消することが目的の一つであったのではないかと推測しています。

Reactの特徴としてよく挙げられる宣言的であるという性質はReactにおいて重要な特性の一つではあると思いますが、それはどちらかといえば参照透過性によってもたらされる副次的な要素であるのではないかと個人的には考えています。

また、Reactにおけるコンポーネントの実態はpropsまたはStateを受け取ってVNodeというオブジェクトを返却する関数と考えられます。もしコンポーネントがStateやEffect, Contextなどに依存せず、propsのみに依存しているのであれば、コンポーネントを副作用のない純粋関数として実装することもできます。そういったコンポーネントは副作用もなく特定の文脈などへの依存も少ないため、容易に再利用ができますし、レンダリング結果の予測やテストコードの記述なども容易に行えます。

こういった点などを基に考えると、Reactというのは副作用や参照透過性といったものの存在を念頭において設計することで、高い再利用性やレンダリング結果の予測などを可能とすることを目的としたフレームワークであると考えます。

実践編

Rules of Reactのルールについていくつか紹介しました。具体的にRules of Reactを実際のアプリケーション開発に適用するにはどうすれば良いでしょうか?

React公式から推奨されている方法などについて紹介します。

<StrictMode>を有効化する

<StrictMode>とはReactフレームワークによって提供されているコンポーネントです。

この<StrictMode>が有効化されると、Reactは意図的にあるコンポーネントを複数回余分にレンダリングしたり、Effectを余分に実行します。

この振る舞いにより、もしComponents and Hooks must be idempotentSide effects must run outside of renderなどのルールに違反しているコンポーネントやフックが存在する場合に、意図せぬ振る舞いを引き起こす可能性があります。

<StrictMode>は問題のあるコンポーネントやフックを特定するのに役立ち、導入も比較的しやすいと思われるため、ぜひ導入しておくと便利です。

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

const root = document.getElementById('root');
createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>
);

eslint-plugin-react-hooksを導入する

eslint-plugin-react-hooksについてはおそらく使ったことがある方も多いのではないかと思います。

www.npmjs.com

Rules of Reactで紹介されているルールのうち、Rules of Hooksに違反しているコードを検出してくれます。

github.com

ESLintプラグインであり導入のハードルなども比較的低いと考えられるため、これについてはぜひ導入をしておくとよいでしょう。

eslint-plugin-react-compilerを導入する

eslint-plugin-react-compilerはReact公式によって開発されているESLintプラグインです。

www.npmjs.com

React QueryやMUI (Material UI)などの有名なライブラリでもすでに導入されており、ある程度大規模なプロジェクトにおいても少しずつ運用が進められているようです。

github.com github.com

このプラグインを設定しておくことで、例えば、コンポーネントやフックからグローバル変数などに対して操作を行おうとすると、以下のようなエラーが発生します。

Writing to a variable defined outside a component or hook is not allowed. Consider using an effect  react-compiler/react-compiler

同様に、コンポーネントやフックからグローバル変数に対して再代入を行うと、以下のようなエラーが発生します。

Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)

このようにeslint-plugin-react-compilerはRules of Reactに違反しているコンポーネントやフックなどのコードを検出してくれます。結構厄介そうなバグとかも拾ってくれそうなので、個人的にはかなり便利なのではないかと感じます。

現時点だとESLint v9のFlat Configにはまだ対応していなさそうなので、もしFlat Configと併用したい場合は@eslint/compatを利用する必要がありそうです。

import { fixupPluginRules } from '@eslint/compat';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
import pluginReactCompiler from 'eslint-plugin-react-compiler';

export default tseslint.config(
  ...tseslint.configs.recommended,
  pluginReact.configs.flat.recommended,
  // ... 省略 ...
  {
    plugins: {
      'react-compiler': fixupPluginRules(pluginReactCompiler)
    },
    rules: {
      'react-compiler/react-compiler': 'error',
    },
  },
);

React Compilerの導入そのものはまだ早いとは思いますが、eslint-plugin-react-compilerについてはESLintプラグインであり導入も比較的しやすいとは思うため、もし可能なら今のうちに入れておくのもよいと思います。

おわりに

以上、Rules of Reactを基にReactについて改めて見ていきました。現時点ではドキュメントの分量もそこまで多くはないので、比較的スムーズに読み進めやすいのではないかと思います。最新のドキュメントは以下のリンクから閲覧いただけます。

react.dev

また、今回、eslint-plugin-react-compilerを試してみたのですが、触ってみた感覚としてはとても便利な印象を受けました。コードレビューなどでは気付きにくい厄介な問題なども検出してくれそうなため、もし余裕がありそうなら導入をしてみるとよさそうに感じました。