💬

Reactの魔法を理解する

に公開1

僕はバックエンドエンジニアなんですが、モノを作ろうとするとどうしてもフロントを触る必要があります。
ここではReactを触れるようになるための最低限の知識をまとめておこうと思います。
できるだけ「なぜそうなるのか?」を深堀りながら書きたいと思います。

AI使いながら書いてます。Reactの公式ドキュメントも参考にしてます。


Reactのコードは初見には「魔法」だらけです。急に出てきたreact hooksという呪文を丸暗記するのは辛い。hooks導入の背景を理解し、呪文を呪文で無くします。

1. 原理原則: UI = f(state)

Reactのすべては、この数式に集約されます。

  • f (関数): コンポーネントそのもの(JSXを返す関数)。
  • state (引数): useState で管理される値。
  • UI (戻り値): 画面の表示内容(仮想DOM)。

再描画の定義

Stateが更新されると、Reactは新しい state を引数にして再び f を実行します。これが「再描画」です。
f は純粋関数であり、「同じ state が渡されれば、常に同じ UI を返す」 必要があります。


2. useState

2-1. useState hookがある理由: DOM更新が重い!

f が実行されるたびにブラウザのDOMを直接書き換えていると、処理が遅すぎます(I/Oコストが高い)。
そこでReactは「仮想DOM」を使います。

  • Virtual DOM: 高速なオンメモリ上で UI を計算し、前回との差分だけを実際のDOMに適用(Push)する仕組み。
  • Batching: State更新のリクエストを溜め込み、一度の再描画でまとめて処理する仕組み。

この「Batching」を実現するためには、Reactが変数の変更を 完全に管理(Intercept) する必要があります。その仕組みがuseState hookです。


2-2. setAge(5) とは age = 5 である

プログラマとしては本来、以下のように書きたいはずです。

// 理想 (Pure JS)
age = 5; 
// → これで勝手に再描画されてほしい

しかし、単なる変数代入(age = 5)では、React Engineは「変更されたこと」に気づけません。
そこで、「代入」という行為を関数でラップし、フレームワークを経由させるという制約を入れました。

// 現実 (React)
setAge(5);
// → 「age = 5 にしたいです」とReact Engineに伝える

つまり、setAge がやっていることは以下の2つだけです。

  1. 値の更新: 本質的には age = 5 と同じ。
  2. 通知: 「値が変わったから、タイミングを見て再描画してね」とReact Engineに依頼する。

「魔法」のように見えますが、単なる 「通知機能付きの代入」 です。


2-3. スナップショットとキュー

では、その「通知」を受けたReact Engineはどう動くのか。
こんな感じの理解です。キーワードは「スナップショット」と「キュー」。

function sampleComponent(){
  const [age, setAge] = useState(0)
  ...

// ① Snapshot: この関数実行回においては age は 初期値で固定。
// 仮に前回の結果が 2 だとすると、ここでの age は常に 2 である。
  const handleClick = () => {
    
    console.log(age); // 2

    // ② Queueing: React Engineへのリクエスト
    // 注意: ageは2なので setAge(7) と同じ
    setAge(age + 5);            
    // -> Queue: [ { type: 'replace', val: 7 } ]

    // 意地悪な追記: ここで別の値をセットしてみる
    // 注意: ここでも age は 2 のまま。つまり setAge(9) と同じ
    setAge(age + 7);
    // -> Queue: [ { type: 'replace', val: 7 }, { type: 'replace', val: 9 } ]

    // 「今の値(prev)に +1 して」という依頼を積む
    // ここだけは直前の計算結果を受け取る関数
    setAge((prev) => prev + 1); 
    // -> Queue: [ Replace(7), Replace(9), Update(+1) ]

    // ③ End: JSの処理が終わり、React Engineが動き出す
  };

  return ...
}

流れ

  1. Queue計算:
  • setAge は即時実行ではなく「依頼」なので、キューに溜まります。
  • React Engineはキューを順に処理し、2799+1という計算を行います。
  1. 再描画:
  • 計算結果 10 を引数にして f(10) を実行します。

3. useEffect

UI描画以外のすべての処理(API通信、タイマー、ロギング...etc)のことを副作用と呼びます。
この副作用を管理するのがuseEffect hookです。

3-1. なぜ副作用を f(state) に書いてはいけないのか?

UI = f(state) の原則において、f は「画面を計算するだけの純粋な関数」であるべきです。
もし、この関数の中に直接 fetch() などの副作用(不純物)を書くとどうなるでしょうか?

// 悪い例
export default function Component() {
  const [data, setData] = useState(null);

  // レンダリング(関数の実行)のたびに通信が走る!
  fetch("/api/user").then(res => {
    // データ取得後にStateを更新すると、再レンダリング(関数の再実行)が走る
    setData(res.data); 
  });
  
  // → 再実行されるとまた fetch が走る
  // → また setData される... (無限ループ)

  return <div>...</div>;
}

結論:
計算ロジック(Render)と外部通信(Side Effect)は、場所を明確に分ける必要があります。

3-2. 解決策: 副作用の隔離

Reactはこの問題を解決するために 「計算が終わって、画面がブラウザに反映された後」 に実行される特別な場所を用意しました。それが useEffect です。

  • Render Phase: f(state) の実行。純粋な計算のみ。
  • Commit Phase: ブラウザへの反映。
  • Effect Phase: ここで副作用を実行する。

ユーザーに見せる画面の表示(メインスレッド)をブロックしないよう、重い処理は最後に回されます。

3-3. 依存配列

毎回レンダリングのたびに通信が走るのは無駄です。
「必要なときだけ実行する」ためのフィルタリング機能として、第2引数の配列が存在します。

useEffect(() => {
  // 副作用の処理
}, [dependency]);

指定 実行タイミング
[] (空配列) 初回のマウント(画面表示)直後のみ
[id] (変数あり) 前回の id と比較して値が変わった時のみ
なし 毎回のレンダリング直後

※「毎回」とは、State更新などでコンポーネント関数が再実行され、画面への反映が終わった直後を指します。

3-4. クリーンアップ関数

SPAでは、画面遷移してもページのリロードが発生しないため、メモリがリセットされません。
そのため、setIntervalWebSocket 接続などをやりっ放しにすると、メモリリークが発生します。

Reactは、コンポーネントが破棄される前や、次のEffectが走る前に「後始末」をする仕組みを提供しています。

useEffect(() => {
  // 1. 接続開始
  const ws = new WebSocket(url);

  // 2. クリーンアップ関数を返す (return)
  return () => {
    ws.close(); // 接続終了
  };
}, [url]);

useEffectのまとめ

  1. 純粋関数 f(state) を守るためには、不純物を隔離する場所が必要だ。
    useEffect の誕生。
  2. その場所は、画面表示を邪魔しない「反映後」であるべきだ。
    → 非同期的な実行タイミング。
  3. 毎回走ると重いから、実行条件(依存配列)でフィルタリングするべきだ。
    → 依存配列による制御。
  4. やりっ放しはメモリリークするから、後始末(クリーンアップ)の仕組みが必要だ。
    return 関数による破棄。

(おまけ: useSWR)

データフェッチングでは普通はuseEffectではなくuseSWRを使う。

import useSWR from 'swr';

// fetcher: 単なるfetchのラッパー関数
const fetcher = (url) => fetch(url).then((res) => res.json());

export default function Profile() {
  // useEffect, useState(data, loading, error) が全部ここに入っている
  const { data, error, isLoading } = useSWR('/api/user/123', fetcher);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;

  return <div>Hello, {data.name}!</div>;
}

4. useRef

useRefは、「UI再描画を伴わない記憶領域」 です。

4-1. useState との違い

useStateuseRef はどちらも「値を保持する」機能ですが、React Engineへの通知(再描画リクエスト)の有無が異なります。

  • useState: 「値を変える。そして画面も書き換えてほしい。」(通知あり)
  • useRef: 「値を変える。でも画面はそのままでいい(こっそり持っておきたい)。」(通知なし)

4-2. メンタルモデル:コンポーネントの「ポケット」

関数コンポーネントは再描画のたびに実行され、変数はリセット(再定義)されます。しかし、useRef に入れた値だけは、再描画をまたいで生き残ります。

function Timer() {
  const [count, setCount] = useState(0);
  // ref.current に入れた値は、再レンダリングされてもリセットされない
  // また、書き換えても再レンダリングは発生しない
  const intervalRef = useRef(null); 

  const start = () => {
    // 既存のタイマーIDを「ポケット」に入れておく
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stop = () => {
    // 「ポケット」から取り出して停止する
    clearInterval(intervalRef.current);
  };

  return ...
}

4-3. もう一つの用途:DOMへの直接アクセス

Reactは仮想DOMを使いますが、たまに「実際のDOM(Real DOM)」を触る必要があります(例:inputにフォーカスを当てる、スクロール位置を測る)。
この「DOM要素の参照」を保持するためにも useRef が使われます。


5. メモ化 (Performance Tuning)

AIが書いたコードは動きますが、無駄な再描画が多いことがあります。プロダクトレベルでは「重い処理を省く」知識が必要です。

5-1. Reactの再描画ルール:親が動けば子も動く

Reactのデフォルトの挙動は 「親コンポーネントが再描画されたら、子コンポーネントも無条件で再描画(関数の再実行)する」 です。これは、子コンポーネントの props が変わっていなくても発生します。

巨大なリストやグラフなどを表示している場合、これではパフォーマンスが落ちます。そこで「メモ化(キャッシュ)」を使います。

5-2. useMemo & useCallback

「前回と同じ計算結果(または関数)なら、再計算せずに使い回す」ためのフックです。

  • useMemo (値のキャッシュ):
    重い計算結果を保存します。
// 依存配列 [data] が変わらない限り、重い計算をスキップして前回の結果を返す
const expensiveValue = useMemo(() => computeHeavyData(data), [data]);

  • useCallback (関数のキャッシュ):
    ここが初学者のつまづきポイントです。
    JavaScriptでは function() {} === function() {}false です。つまり、コンポーネントが再描画されるたびに、関数は「別物(新しいメモリアドレス)」として作り直されます。
    これを「前回と同じ実体(アドレス)」に固定するのが useCallback です。
// 再描画されても、関数を再生成しない(アドレスを固定する)
const handleClick = useCallback(() => {
  doSomething(id);
}, [id]); 

5-3. React.memo

コンポーネント自体をメモ化します。「Propsが変わっていなければ、親が再描画されても私は再描画しません」という宣言です。

const ChildComponent = React.memo(function Child({ onClick }) {
  console.log("Rendered");
  return <button onClick={onClick}>Click</button>;
});

重要: React.memo を有効にするには、Propsとして渡す関数を useCallback で固定しておく必要があります(そうしないと、毎回「違う関数が来た」と判定されて再描画されてしまうため)。

5-まとめ: いつ何を使うのか?

名前 何を保存する? いつ使う?
React.memo コンポーネントの結果 子コンポーネントに使う。「親が再描画されても、私のPropsが変わってなければ私は再描画しないで」と宣言する時。
useCallback 関数定義 親コンポーネントで使う。React.memo化された子に関数を渡す時。これがないとReact.memoが意味をなさなくなる。
useMemo 計算結果(値) コンポーネント内で使う。配列のフィルタリングや複雑な計算など、「計算コストが高い処理」を毎回やりたくない時。

6. Context API (Props Drillingの回避)

コンポーネントの階層が深くなると、親からひ孫へデータを渡すのが辛くなります(バケツリレー)。

6-1. テレポーテーション

Contextを使うと、中間のコンポーネントを飛ばして、必要な値を下の階層へ直接配信できます。

  • Provider: 放送局。値を配信する範囲を囲む。
  • useContext: 受信機。階層に関係なく値を受け取る。
// 1. Context作成
const UserContext = createContext(null);

// 2. 放送局 (親)
function App() {
  const [user, setUser] = useState({ name: "Alice" });
  return (
    <UserContext.Provider value={user}>
      <GrandParent /> {/* 中間コンポーネントはpropsを渡さなくていい */}
    </UserContext.Provider>
  );
}

// 3. 受信機 (ひ孫)
function GreatGrandChild() {
  const user = useContext(UserContext); // { name: "Alice" } が直接取れる
  return <div>{user.name}</div>;
}

※ AIにコードを書かせると、しばしば巨大なPropsリレーが発生するので、Contextや状態管理ライブラリ(Zustandなど)へのリファクタリング指示が必要になります。


7. Custom Hooks (ロジックの再利用)

AIコーディング時代の最重要項目です。
コンポーネント(f(state))の中に、複雑な useEffectuseState のロジックが混ざると可読性が死にます。

7-1. UIとロジックの分離

「React Hooksを組み合わせたロジック」を関数として切り出したものがカスタムフックです。名前は必ず use で始めます。

Before (ごちゃ混ぜ):

function UsersList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
     // ...複雑な通信処理
  }, []);

  if (loading) return <div>Loading...</div>;
  return <div>{users.map(...)}</div>;
}

After (分離):

// Logic (useUsers.ts)
function useUsers() {
  // 通信やState管理はここに隠蔽
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  useEffect(() => { ... }, []);
  
  return { users, loading };
}

// UI (UsersList.tsx)
function UsersList() {
  // コンポーネントは「表示」に集中できる
  const { users, loading } = useUsers(); 

  if (loading) return <div>Loading...</div>;
  return <div>{users.map(...)}</div>;
}

AIに「このロジックをカスタムフックに切り出して」と指示するだけで、コードの品質とメンテナンス性が劇的に向上します。


まとめ:プロダクト開発に必要な知識マップ

  1. State/Effect (基礎): useState, useEffect
  2. Ref (保持): useRef(再描画しない値、DOM操作)
  3. Performance (最適化): useMemo, useCallback, React.memo(無駄を省く)
  4. Global State (共有): Context API(バケツリレーの回避)
  5. Abstraction (設計): Custom Hooks(UIとロジックの分離)

Discussion

benjuwanbenjuwan

こんにちは!
端的に分かりやすい記事をありがとうございます。

6. Context API (Props Drillingの回避)で紹介されているProviderですが、React 19になってProviderの記述が不要になっています。

const ThemeContext = createContext('');

function App({children}) {
  return (
-    <ThemeContext.Provider value="dark">  
+    <ThemeContext value="dark">
      {children}
+    </ThemeContext>
-    </ThemeContext.Provider>
  );  
}

データフェッチングでは普通はuseEffectではなくuseSWRを使う。

こちらは余談で、参考までにですが React でのデータフェッチでは記事で紹介されている SWR以外にもTanstack Queryというものもあります。