dev React

React useState と useEffect を理解する — 状態管理と副作用の基本

React の useState と useEffect は、コンポーネントの状態管理と副作用処理を担う最も重要な Hook です。この記事では、実際にカウンターやタイマーを作りながら、これらの仕組みをステップバイステップで解説します。

注記: 記事中のユーザー名やPC名はダミーに差し替えています。

参照元

得られるもの

React の useState と useEffect を使いこなすための基礎知識を、ハンズオン形式で身につけることができます。

  • useState の基本構文と関数型更新の使い分け
  • state が更新されると再レンダリングが起きる仕組み
  • フォーム入力を useState で管理する「制御コンポーネント」パターン
  • useEffect の依存配列の使い分け(初回のみ、値の変化時、毎回)
  • API 通信やタイマー処理での useEffect の実践的な使い方
  • クリーンアップ関数によるリソース解放の考え方

C/C++ や C# のバックグラウンドがある方にも分かりやすいよう、馴染みのある概念と対比しながら進めていきます。

前提条件

この記事は、以下の知識がある前提で進めます。

  • React の JSX 記法、関数コンポーネント、Props の基本を理解している
  • イベントハンドリング(onClick, onChange など)を理解している
  • Vite + React + TypeScript の開発環境が構築済み

まだの方は、以下の記事で環境構築とイベントハンドリングまでをカバーしています。

  • JSX・Props・イベントハンドリングの基本(前回の記事)

useState — コンポーネントの状態管理

useState の基本構文

useState は「コンポーネントが持つ状態(state)」を定義する Hook です。基本構文はこのようになります。

import { useState } from 'react';

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

useState(0) で初期値 0 の state を作り、count が現在の値、setCount が値を更新する関数です。C# の プロパティと setter に近い感覚ですが、大きな違いがあります。それは「setCount を呼ぶとコンポーネントが再レンダリングされる」という点です。

なぜ普通の変数ではダメなのか

React では、普通の変数(let count = 0)を変更しても画面は更新されません。React は state の変更をトリガーにして「このコンポーネントを再描画する必要がある」と判断します。普通の変数を書き換えても、React はその変更に気づけないのです。

さらに重要なのは、setCount は即座に値を変更しないという点です。C/C++ ではメモリ上のデータを直接書き換えますが、React は setState の呼び出しをキューに溜めて、まとめて 1 回だけ再レンダリングします。

setCount(count + 1);  // ここで DOM 更新…ではない
setName("太郎");      // ここでも DOM 更新…ではない
setAge(30);           // ここでも DOM 更新…ではない
// → 3つをまとめて1回だけ再レンダリング(バッチ処理)

DOM の操作はメモリ上の変数代入と比べて桁違いに重いため、このバッチ処理がパフォーマンスの鍵になります。C/C++ の制御系で例えると、割り込みハンドラ内でフラグだけセットしておいて、メインループでまとめて処理するパターンに近い考え方です。

関数型更新(prev => prev + 1)

state の更新方法には2つのパターンがあります。

// パターン1: 直接値を指定
setCount(count + 1);

// パターン2: 関数型更新(推奨)
setCount(prev => prev + 1);

パターン1は一見シンプルですが、同じレンダリング内で複数回呼ぶと問題が起きます。

// パターン1で2回呼んだ場合
setCount(count + 1); // count=0 → 0+1=1 をセット予約
setCount(count + 1); // count=0 → 0+1=1 をセット予約(まだ同じ count!)
// 結果: 1(2にならない)

setState は即座に値を変えないため、同じレンダリング内では count は古い値のままです。一方、関数型更新なら React が前回の結果を次の関数に渡してくれます。

// パターン2で2回呼んだ場合
setCount(prev => prev + 1); // prev=0 → 1
setCount(prev => prev + 1); // prev=1 → 2(更新後の値を受け取る)
// 結果: 2(正しい)

前の値に基づいて更新する場合は、関数型更新を使うのが安全です。

カウンターの実装

ここまでの知識を使って、カウンターを実装してみます。

React useState カウンターの初期表示画面
React useState カウンターの+5 -5 リセットボタン付き画面
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  const handleReset = () => {
    setCount(0);
  };

  const handleAddFive = () => {
    setCount(prev => prev + 5);
  };

  const handleSubtractFive = () => {
    setCount(prev => prev - 5);
  };

  return (
    <div className="p-6 max-w-sm mx-auto bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-4 text-center">カウンター</h2>
      <div className="text-4xl font-bold text-center mb-6 text-blue-600">
        {count}
      </div>

      <div className="flex gap-2 justify-center mb-4">
        <button
          onClick={() => setCount(prev => prev + 1)}
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
        >
          +1
        </button>
        <button
          onClick={() => setCount(prev => prev - 1)}
          className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
        >
          -1
        </button>
      </div>

      <div className="flex gap-2 justify-center mb-4">
        <button
          onClick={handleAddFive}
          className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
        >
          +5
        </button>
        <button
          onClick={handleSubtractFive}
          className="bg-orange-500 text-white px-4 py-2 rounded hover:bg-orange-600"
        >
          -5
        </button>
      </div>

      <div className="flex justify-center">
        <button
          onClick={handleReset}
          className="bg-gray-500 text-white px-6 py-2 rounded hover:bg-gray-600"
        >
          リセット
        </button>
      </div>
    </div>
  );
}

export default Counter;

console.log を追加して開発者ツールのコンソールを確認すると、ボタンを押すたびにコンポーネント関数が再実行されていることが分かります。これが「state の更新 → 再レンダリング」の仕組みです。

React useState 再レンダリングのコンソールログとStrictMode確認

なお、React.StrictMode(開発環境のデフォルト)では、副作用のバグを検出するためにコンポーネントが意図的に2回実行されます。コンソールのログが2重に出ても、本番ビルドでは1回だけになるので問題ありません。

実践的な useState

フォーム入力の管理(制御コンポーネント)

テキストエリアの入力内容を useState で管理する例です。

React useState 制御コンポーネントによるメッセージ入力とリアルタイム反映
import { useState } from 'react';

function MessageApp() {
  const [message, setMessage] = useState('');

  return (
    <div className="p-6 max-w-md mx-auto bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-4 text-center">メッセージアプリ</h2>

      <div className="mb-4">
        <label className="block text-sm font-medium text-gray-700 mb-2">
          メッセージを入力してください
        </label>
        <textarea
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          rows={4}
          placeholder="こんにちは!今日はいい天気ですね。"
        />
      </div>

      <div className="p-4 bg-gray-100 rounded-md">
        <h3 className="text-lg font-bold mb-2">入力されたメッセージ</h3>
        <p className="text-blue-600">{message}</p>
        <p className="text-sm text-gray-500 mt-2">
          文字数: {message.length}文字
        </p>
      </div>
    </div>
  );
}

export default MessageApp;

valueonChange を組み合わせることで、入力内容が常に state と同期します。このパターンを「制御コンポーネント(Controlled Component)」と呼びます。state が「信頼できる唯一の情報源(Single Source of Truth)」になるのがポイントです。

C# の WinForms で例えると、TextBox.Text プロパティ(= value)と TextChanged イベント(= onChange)の関係に近い感覚です。

ちなみに、valuerowsplaceholder は HTML 標準の属性ですが、onChange は React が HTML の onchange をラップした独自のイベントハンドラです。HTML の属性名がキャメルケース(onChange)になる点に注意してください。

TypeScript での型エラー対処

教材は JavaScript 前提で書かれているため、TypeScript で書くと型エラーに遭遇する場面があります。学習中に遭遇した代表的なケースを紹介します。

textarea の rows 属性

// NG: 文字列として渡してしまう
<textarea rows="4" />

// OK: 数値として渡す
<textarea rows={4} />

HTML の属性はすべて文字列ですが、React(TypeScript)では rowsnumber 型を期待します。{} で囲んで JavaScript の式として評価させましょう。

null を初期値にする useState

API から取得するデータなど、初期値が null の state を扱う場合は、ジェネリクスで型を指定します。

// NG: TypeScript は user を null 型としか推論できない
const [user, setUser] = useState(null);
user.name; // エラー: 'null' に 'name' は存在しない

// OK: ジェネリクスで User | null を指定
interface User {
  name: string;
  email: string;
}
const [user, setUser] = useState<User | null>(null);

C# で例えると User? user = null; と同じ感覚です。「null かもしれないけど、値が入るときは User 型だよ」とコンパイラに教えています。

useEffect — サイドエフェクトの管理

useEffect の基本構文と依存配列

useEffect は「レンダリング以外の処理(サイドエフェクト)」を実行するための Hook です。API 呼び出し、DOM 操作、タイマー設定などが該当します。

import { useState, useEffect } from 'react';

useEffect(() => {
  // 実行したい処理
}, [依存配列]);

依存配列の使い分け

依存配列の指定によって、useEffect の実行タイミングが変わります。

依存配列実行タイミング用途
[](空配列)初回マウント時のみAPI の初回呼び出し、初期化処理
[value]value が変わるたびに特定の値の変化に応じた処理
省略毎回のレンダリングほぼ使わない(パフォーマンスに注意)

以下のコードで動作を確認できます。

function BasicEffect() {
  const [count, setCount] = useState(0);

  // 初回マウント時のみ実行
  useEffect(() => {
    console.log('コンポーネントが表示されました');
  }, []);

  // count が変わるたびに実行
  useEffect(() => {
    console.log('countが変更されました:', count);
  }, [count]);

  return (
    <div className="p-6 max-w-md mx-auto bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-4 text-center">useEffect の基本</h2>
      <div className="text-center mb-4">
        <p className="text-lg mb-2">カウント: {count}</p>
        <button
          onClick={() => setCount(prev => prev + 1)}
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
        >
          +1
        </button>
      </div>
    </div>
  );
}
React useEffect 依存配列による実行タイミングのコンソールログ

ボタンを押すと、[count] を依存配列に持つ useEffect だけが実行され、[] の方は実行されません。コンソールで確認してみてください。

API 通信でデータを取得

useEffect の実践的な使い方として、API からデータを取得する例を見てみましょう。

import { useState, useEffect } from 'react';

interface User {
  name: string;
  email: string;
}

function UserData() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
        const userData = await response.json();
        setUser(userData);
      } catch (error) {
        console.error('データの取得に失敗しました:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, []);

  if (loading) {
    return (
      <div className="p-6 max-w-md mx-auto bg-white rounded-lg shadow-md">
        <p className="text-lg text-center">データを読み込み中...</p>
      </div>
    );
  }

  return (
    <div className="p-6 max-w-md mx-auto bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-4 text-center">ユーザー情報</h2>
      {user && (
        <div className="space-y-2">
          <div className="p-3 bg-gray-100 rounded">
            <p className="text-sm text-gray-600">名前</p>
            <p className="font-bold text-lg">{user.name}</p>
          </div>
          <div className="p-3 bg-gray-100 rounded">
            <p className="text-sm text-gray-600">メールアドレス</p>
            <p className="font-bold">{user.email}</p>
          </div>
        </div>
      )}
    </div>
  );
}

export default UserData;

ポイントは3つあります。useEffect(() => { ... }, []) で初回マウント時に1回だけ API を呼ぶこと、loading state でローディング中の表示を切り替えること、そして try/catch/finally でエラーハンドリングを行うことです。

開発者ツールの Network タブで回線速度を「3G」に設定すると、「データを読み込み中…」のローディング表示を目視で確認できます。高速回線だと一瞬で切り替わってしまうので、こうした確認テクニックは実務でもよく使います。

React useEffect API通信中のローディング表示と3Gスロットリング確認
React useEffect API通信完了後のユーザー情報表示

タイマーとクリーンアップ関数

useEffect でタイマーを使う場合、コンポーネントが画面から消えるときにタイマーを停止する必要があります。これを「クリーンアップ」と呼びます。

import { useState, useEffect } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(3);
  const [isActive, setIsActive] = useState(false);

  useEffect(() => {
    let interval: number | null = null;

    if (isActive && seconds > 0) {
      interval = setInterval(() => {
        setSeconds(prev => prev - 1);
      }, 1000);
    }

    // クリーンアップ関数
    return () => {
      if (interval) {
        clearInterval(interval);
      }
    };
  }, [isActive, seconds]);

  const startTimer = () => setIsActive(true);
  const resetTimer = () => {
    setIsActive(false);
    setSeconds(3);
  };

  return (
    <div className="p-6 max-w-md mx-auto bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-4 text-center">3秒タイマー</h2>
      <div className="text-center">
        <div className="text-6xl font-bold mb-6 text-blue-600">{seconds}</div>
        {seconds === 0 ? (
          <div className="mb-4">
            <p className="text-2xl font-bold text-green-600 mb-4">時間です!</p>
            <button
              onClick={resetTimer}
              className="bg-green-500 text-white px-6 py-2 rounded hover:bg-green-600"
            >
              リセット
            </button>
          </div>
        ) : (
          <div className="space-x-4">
            <button
              onClick={startTimer}
              disabled={isActive}
              className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600 disabled:bg-gray-400"
            >
              {isActive ? 'カウント中...' : 'スタート'}
            </button>
            <button
              onClick={resetTimer}
              className="bg-gray-500 text-white px-6 py-2 rounded hover:bg-gray-600"
            >
              リセット
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

export default Timer;
React useEffect タイマー機能の初期表示
React useEffect タイマー完了時の表示

return () => clearInterval(interval) がクリーンアップ関数です。これは自分で呼び出すものではなく、React が以下のタイミングで自動的に呼んでくれます。

  • コンポーネントが画面から消える(アンマウント)とき
  • 依存配列の値が変わって、Effect が再実行される直前

C# で例えると IDisposable.Dispose() パターンに近い感覚です。Dispose() の中身は自分で書くけど、呼び出しは using ブロックやフレームワークが面倒を見てくれる、あの仕組みと同じです。

setInterval 以外にも、クリーンアップが必要な代表的なパターンがあります。

処理セットアップクリーンアップ
タイマー(繰り返し)setInterval(...)clearInterval(...)
タイマー(1回)setTimeout(...)clearTimeout(...)
イベントリスナーaddEventListener(...)removeEventListener(...)
WebSocketnew WebSocket(...)ws.close()

共通する考え方は「外部リソースを確保したら、解放する」です。C/C++ の malloc/free や C# の IDisposable と同じ原則です。判断基準もシンプルで、「放っておいても勝手に止まるか?」を考えれば OK です。止まらないものにはクリーンアップが必要です。

まとめ

この記事では、React の useState と useEffect について学びました。振り返ると、以下の内容をカバーしました。

  • useState は「コンポーネントの状態」を管理し、更新すると再レンダリングが起きる
  • setState はバッチ処理されるため即座に値が変わらない。前の値に基づく更新には関数型更新(prev => prev + 1)を使う
  • フォーム入力は useState で管理する「制御コンポーネント」パターンが基本
  • useEffect は依存配列で実行タイミングを制御する。[] は初回のみ、[value] は値の変化時
  • タイマーやイベントリスナーなど「放っておいても止まらない処理」にはクリーンアップ関数が必要
  • TypeScript で書く場合、教材(JavaScript 前提)の型エラーは自分で補完する

C/C++ や C# の経験があると、バッチ処理の概念やリソース解放の考え方は馴染みやすいと思います。次の章ではさらに多くの React Hooks を学んでいきます。

それでは、また別の記事でお会いしましょう。最後まで読んでいただきありがとうございました!

これまでの学習内容

この記事で紹介した内容の他にも学習した内容を記事にしています。是非、ご覧になってみて下さい。

React入門 Vite + React + TypeScriptで開発環境を構築する手順をハンズオン形式で解説
Vite React TypeScript setupでTodoAppの開発環境を構築する

Vite React TypeScript setup の手順を、実際のハンズオンを通じてステップバイステップで解説します。この記事は、筆者がReact学習の第一歩として環境構築を行った際の体験をもと ...

続きを見る

react jsx propsの基本を他言語経験者向けに解説する入門記事のアイキャッチ
React入門:JSX・コンポーネント・Propsを理解する【他言語経験者向け】

Reactの学習を始めて、JSXの書き方、関数コンポーネント、Propsの仕組みまで一通り手を動かしてみました。この記事では、react jsx propsの基本を他言語経験者の視点で整理していきます ...

続きを見る

react event handlingの基本をクリック・入力・フォームイベントのハンズオンで解説する入門記事のアイキャッチ
React Event Handling入門:クリック・入力・フォームイベントをハンズオンで学ぶ

React event handlingの基本を、実際にコードを書いて動かしながら学びました。この記事では、クリックイベント、入力イベント、イベントオブジェクト、デフォルト動作の防止まで、ハンズオンで ...

続きを見る

React useState useEffect 状態管理と副作用処理の基本をハンズオンで学ぶ
React useState と useEffect を理解する — 状態管理と副作用の基本

React の useState と useEffect は、コンポーネントの状態管理と副作用処理を担う最も重要な Hook です。この記事では、実際にカウンターやタイマーを作りながら、これらの仕組み ...

続きを見る

React useContext Props drillingを解消する状態共有の仕組みをハンズオンで学ぶ記事のアイキャッチ画像
React useContextで学ぶ状態共有 — Props drillingからの脱却

Reactでコンポーネントの階層が深くなると、「親から子へ、子から孫へ」とPropsをバケツリレーのように渡し続ける場面に出会います。これが Props drilling と呼ばれる厄介な問題です。u ...

続きを見る

-dev, React