プログラマのための IT 教科書

React で画面を組み立てる

この部の 7 / 9 章 ・ 全体で 40 / 76 章 ・ 読了目安 50 分

この章を読むとできるようになること
  • 状態から画面を組み立てる考え方を説明できる
  • state をどこに置くか判断できる
  • 再描画とデータ取得の典型的な失敗を避けられる

前章の HTML を、手で書き換えていくとどうなるかを考えます。

// カートに商品を追加した時、更新しなければならない場所
document.querySelector('.cart-count').textContent = items.length;
document.querySelector('.cart-total').textContent = total;
document.querySelector('.checkout-btn').disabled = items.length === 0;
document.querySelector('.empty-message').style.display = items.length ? 'none' : 'block';

画面が増えるほど、「データが変わった時に、どこを更新すべきか」を 人間が全部覚えている必要が出てきます。1箇所忘れると、 画面とデータがずれます。これが DOM を直接操作する方式の限界です。

React は、この問題を発想を逆転して解決します。

UI は状態の関数である

UI = f(state)

「どこをどう更新するか」を書くのをやめ、 「この状態の時、画面はこうなる」だけを書きます。 状態が変わったら、React が新しい画面全体を計算し直し、 実際の DOM との差分だけを適用します。

function Cart({ items }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return (
    <div>
      <span>{items.length} 点</span>
      <span>{total} 円</span>
      <button disabled={items.length === 0}>購入する</button>
      {items.length === 0 && <p>カートは空です</p>}
    </div>
  );
}

items が変われば、上の記述と矛盾しない画面に必ずなります。 更新漏れが原理的に起きません。これが React を使う理由のすべてです。

JSX

上のコードで、JavaScript の中に HTML のようなものが書かれています。 これが JSX で、ビルド時に関数呼び出しに変換されます。

<button className="primary">保存</button>
// ↓ 変換後(イメージ)
React.createElement('button', { className: 'primary' }, '保存')

HTML そのものではないので、いくつか違いがあります。

HTMLJSX理由
classclassNameclass は JS の予約語
forhtmlFor同上
onclick="f()"onClick={f}文字列ではなく関数を渡す
<br><br />必ず閉じる
style="color: red"style={{ color: 'red' }}オブジェクトで渡す

{ } の中にはJavaScript の式を書けます(文は書けません)。

{user.name}                          {/* 値 */}
{items.length > 0 && <List />}        {/* 条件付き表示 */}
{isAdmin ? <Admin /> : <Guest />}     {/* 三項演算子 */}
{items.map((i) => <Item key={i.id} />)}  {/* 繰り返し */}

if 文は書けないので、&& と三項演算子を使います。

`&&` の左に数値を置かない
{items.length && <List />}   {/* items が空だと、画面に 0 が表示される */}
{items.length > 0 && <List />}  {/* 正しい */}

0 は falsy ですが、React は 0 を「表示すべき値」として描画します。 必ず boolean にしてから && に渡してください。

コンポーネントと props

コンポーネントは、props を受け取って JSX を返す関数です。

type Props = {
  readonly user: { readonly name: string; readonly avatarUrl: string };
  readonly onSelect: (name: string) => void;
};
 
export function UserCard({ user, onSelect }: Props) {
  return (
    <button onClick={() => onSelect(user.name)}>
      <img src={user.avatarUrl} alt="" />
      <span>{user.name}</span>
    </button>
  );
}

props は読み取り専用です。 受け取った側が書き換えてはいけません。 データは上から下へ流れ、変更は関数(コールバック)を下に渡して依頼します。

       [親]  ← 状態を持つ
        │ props(データ)↓    コールバック ↑
       [子]  ← 表示するだけ

state — 変わる値

import { useState } from 'react';
 
export function Counter() {
  const [count, setCount] = useState(0);
 
  return <button onClick={() => setCount(count + 1)}>{count} 回</button>;
}

useState は「現在の値」と「更新する関数」を返します。 更新関数を呼ぶと、React はそのコンポーネントをもう一度実行し、 返ってきた JSX を前回と比べて、変わった部分だけ DOM に反映します。

state を直接書き換えない
items.push(newItem);      // 何も起きない(React は変化に気づけない)
setItems([...items, newItem]);   // 正しい
 
user.name = 'sato';       // 何も起きない
setUser({ ...user, name: 'sato' });   // 正しい

React は「前の値と新しい値が同一かどうか」で再描画を判断します。 同じオブジェクトを書き換えると、同一と判定されて画面が更新されません。

JavaScript の基礎の「引数を破壊しない」がここで効いてきます。

連続して更新する時は関数形式で
setCount(count + 1);
setCount(count + 1);   // 2 増えず、1 しか増えない

count はこのレンダリング時点の値で固定されているためです。

setCount((c) => c + 1);
setCount((c) => c + 1);   // 正しく 2 増える

前の値から計算する時は、必ず関数を渡してください。

state はどこに置くか

その状態を必要とするコンポーネント全部の、いちばん近い共通の親に置きます。

        [Page]         ← 検索キーワードの state はここ
       /      \
 [SearchBox]  [ResultList]

SearchBox に置くと ResultList から見えないので、親に上げます(リフトアップ)。 逆に、そのコンポーネントの中でしか使わない状態を上に上げてはいけません。 上げるほど再描画の範囲が広がり、コードも追いにくくなります。

派生する値を state にしない

新人が最もよくやる誤りです。

// 悪い: items から計算できるものを、別の state として持っている
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);   // ← 同期し忘れると必ずずれる
 
// 良い: 毎回計算する
const [items, setItems] = useState([]);
const total = items.reduce((sum, i) => sum + i.price, 0);

計算で求まるものは state にしない。 これだけでバグが激減します。

リストと key

{users.map((user) => <UserCard key={user.id} user={user} />)}

key は、React が「前回のどの要素と同じものか」を見分けるための印です。

`key` に配列のインデックスを使わない
{items.map((item, i) => <Row key={i} item={item} />)}

並べ替え・削除・先頭への挿入が起きると、同じ位置に別のデータが来ます。 React は「同じ key = 同じもの」と判断するため、

  • 入力中のテキストが別の行に移る
  • チェックボックスの状態が入れ替わる

といった、再現しにくいバグになります。 データが持つ安定した ID を使ってください。 並べ替えも削除も絶対に起きないリストに限り、インデックスでも構いません。

useEffect — 使う前に疑う

useEffect は、React の外側の世界と同期するためのものです。

useEffect(() => {
  const timer = setInterval(() => setNow(new Date()), 1000);
  return () => clearInterval(timer);   // 後片付け(アンマウント時に呼ばれる)
}, []);   // 依存配列が空 = 最初の1回だけ

外側の世界とは、タイマー・イベントリスナ・ブラウザ API・外部ストアなどです。

state を state に同期させるために使わない
// 悪い: 不要な再描画が2回起き、一瞬古い値が表示される
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
useEffect(() => { setTotal(items.reduce(...)); }, [items]);
 
// 良い
const total = items.reduce(...);

「レンダリング中に計算できることは、レンダリング中に計算する。」

useEffect の中で setState を書いていたら、ほぼ設計を見直す合図です。

データ取得

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${id}`, { signal: controller.signal })
    .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); })
    .then(setUser)
    .catch((err) => { if (err.name !== 'AbortError') setError(err); });
  return () => controller.abort();   // id が変わったら前のリクエストを捨てる
}, [id]);

後片付けが必要なのは、レスポンスの到着順が入れ替わるからです。 id=1 → id=2 と素早く切り替えると、 1 のレスポンスが後に届いて画面に出る、ということが起きます(競合状態)。

実務では、この手の処理を自分で書かず、 データ取得ライブラリ(TanStack Query など)か、 フレームワークの仕組み(次節)に任せるのが普通です。

Next.js — サーバーで描く

React 単体は「ブラウザで動く UI ライブラリ」です。 実務ではフレームワークと組み合わせます。この教科書のサイトも Next.js 製です。

描画方式いつ HTML を作るか向いているもの
CSRブラウザで JS を実行した後管理画面・ログイン後の画面
SSRリクエストのたびにサーバーでユーザーごとに違う内容
SSGビルド時に1回ブログ・ドキュメント(この教科書)
サーバーコンポーネントとクライアントコンポーネント

Next.js の App Router では、既定でサーバー側で実行されます。

// サーバーコンポーネント(既定): DB に直接アクセスできる。JS はブラウザに送られない
export default async function Page() {
  const users = await db.users.findMany();
  return <UserList users={users} />;
}
 
// クライアントコンポーネント: state やイベントを使う場合はこちら
'use client';
export function Counter() { const [n, setN] = useState(0); ... }

境界を意識せずに書くと、 「関数は props として渡せない」といったエラーになります。 サーバーからクライアントへ渡せるのは、シリアライズできるデータだけです。

パフォーマンス

React は再描画が速いので、まずは何もしないのが正解です。

// 測る前にこれを撒かない
const memoized = useMemo(() => heavy(a, b), [a, b]);
const handler = useCallback(() => {...}, []);
export default memo(MyComponent);

useMemo / useCallback / memo は、それ自体にコストがあります。 実際に遅いことを React DevTools の Profiler で確認してから使ってください。 計算量とデータ構造の「測ってから直す」と同じです。

本当に効く対策は、たいてい別のところにあります。

  • 表示件数を減らす(ページング・仮想スクロール)
  • state を必要な範囲まで下ろす(再描画の範囲を狭める)
  • そもそも取得するデータを減らす

TODO リストで、項目を削除すると別の行のチェック状態が入れ替わってしまいます。原因として最も可能性が高いのは?

実務の落とし穴まとめ

  1. state を直接書き換える — 再描画されない。新しい値を作る
  2. 計算できる値を state にする — 同期漏れで必ずずれる
  3. key にインデックス — 並べ替え・削除で状態が入れ替わる
  4. useEffect で state を同期 — レンダリング中に計算する
  5. setCount(count + 1) の連続 — 関数形式 setCount(c => c + 1) を使う
  6. {items.length && ...} — 0 が表示される
  7. state を必要以上に上に置く — 再描画の範囲が広がる
  8. 測る前に memo を撒く — コストだけ増える
  9. useEffect のデータ取得で後片付けをしない — 古いレスポンスが勝つ

まとめ

  • React の中心は UI = f(state)。更新箇所を人間が管理しなくてよくなる
  • props は上から下へ、変更依頼はコールバックで下から上へ
  • state は「変わる最小限の値」だけ。計算で出せるものは計算する
  • state は不変に扱う。新しいオブジェクト・配列を作って渡す
  • リストの key はデータの安定した ID
  • useEffect は外の世界との同期のためだけ。使う前に不要かを疑う
  • Next.js では サーバー / クライアントの境界を意識する
  • 最適化は測ってから。まず件数と state の位置を見直す

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
React 公式(日本語)https://ja.react.dev/
Next.js ドキュメントhttps://nextjs.org/docs
React の「You Might Not Need an Effect」https://ja.react.dev/learn/you-might-not-need-an-effect

章末問題

検索キーワードの入力欄と、その結果一覧が別々のコンポーネントにあります。state はどこに置くべきですか。

`useEffect` の中で `setState` を呼んでいるコードをレビューで見つけました。まず何を確認しますか。

次の章では、この React を使ってチームで実際のサービスの画面を作る時に 必要になることを扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。