Reactの基礎 - useState

提供: MochiuWiki : SUSE, EC, PCB

概要

useState は、Reactが提供する最も基本的なHookであり、関数コンポーネントに状態管理機能を追加するために使用する。
React 16.8以降、Hooksの導入により関数コンポーネントでも状態を持てるようになり、useState はその中核を担う。

基本構文は const [state, setState] = useState(initialValue) であり、配列の分割代入によって現在の状態値と状態更新関数を受け取る。
setState を呼び出すと、Reactはコンポーネントの再レンダリングをスケジュールして、新しい状態値でUIが更新される。

TypeScriptと組み合わせる場合、型推論によって初期値から型が自動的に決定されるが、null 許容型や空配列を使用する場合はジェネリクスによる明示的な型指定が必要となる。

useState を使用する時の基本的な特性を以下に示す。

  • 状態が変更されると、そのコンポーネントと子コンポーネントが再レンダリングされる。
  • 状態はコンポーネントのインスタンスごとに独立して管理される。
  • 状態の更新は非同期で処理され、バッチ化される場合がある。
  • 状態オブジェクトや配列は直接変更せず、新しい値を生成して渡す必要がある。



基本的な使用方法

useState の基本的な使用方法として、構文、型推論、ジェネリクスによる型指定を示す。

構文と型推論

TypeScriptでは、useState に渡した初期値から型が自動的に推論される。

明示的に型を指定する場合は、ジェネリクス構文 useState<T>(initialValue) を使用する。

  • 型推論による暗黙的型指定
    初期値から型が自動的に決定される。
    初期値が false であれば boolean型として推論される。
     import { useState } from 'react';
     
     function ToggleButton() {
        // 初期値 false から boolean 型として推論される
        const [enabled, setEnabled] = useState(false);
        // 初期値 0 から number 型として推論される
        const [count, setCount] = useState(0);
        // 初期値 '' から string 型として推論される
        const [name, setName] = useState('');
     
        return (
           <div>
              <p>有効: {String(enabled)}</p>
              <p>カウント: {count}</p>
              <p>名前: {name}</p>
           </div>
        );
     }
    

  • ジェネリクスによる明示的型指定
    型推論が難しい場合 や 意図を明確にしたい場合にジェネリクスで型を指定する。
     import { useState } from 'react';
     
     function ExplicitTypes() {
        // 明示的にboolean型を指定
        const [enabled, setEnabled] = useState<boolean>(false);
     
        // 空配列は型を推論できないため、明示的な指定が必須
        const [items, setItems] = useState<string[]>([]);
     
        // null許容型 : number または null
        const [selectedId, setSelectedId] = useState<number | null>(null);
     
        return <div>{selectedId !== null ? `ID: ${selectedId}` : '未選択'}</div>;
     }
    

  • Union型の使用
    特定のリテラル型のみを許可するUnion型で状態を管理できる。
     import { useState } from 'react';
     
     type Status = 'idle' | 'loading' | 'success' | 'error';
     
     function FetchButton() {
        const [status, setStatus] = useState<Status>('idle');
     
        const handleFetch = async () => {
           setStatus('loading');
           try {
              await fetch('/api/data');
              setStatus('success');
           }
           catch {
              setStatus('error');
           }
        };
     
        return (
           <div>
              <p>状態: {status}</p>
              <button onClick={handleFetch} disabled={status === 'loading'}>
                 データ取得
              </button>
           </div>
        );
     }
    


プリミティブ型の状態管理

string、number、boolean等のプリミティブ型は、useState で最も簡単に扱える状態の種類である。

 import { useState } from 'react';
 
 function PrimitiveStates() {
    const [text, setText] = useState<string>('');
    const [count, setCount] = useState<number>(0);
    const [isVisible, setIsVisible] = useState<boolean>(true);
 
    return (
       <div>
          {/* string の例 */}
          <input
             type="text"
             value={text}
             onChange={(e) => setText(e.target.value)}
             placeholder="テキストを入力"
          />
          <p>入力値: {text}</p>
 
          {/* number の例 */}
          <button onClick={() => setCount(count + 1)}>増加</button>
          <button onClick={() => setCount(count - 1)}>減少</button>
          <p>カウント: {count}</p>
 
          {/* boolean の例 */}
          <button onClick={() => setIsVisible(!isVisible)}>
             {isVisible ? '非表示にする' : '表示する'}
          </button>
          {isVisible && <p>表示中のコンテンツ</p>}
       </div>
    );
 }



オブジェクトと配列の状態管理

オブジェクトや配列を状態として管理する場合は、Reactの再レンダリングの仕組みにより、直接変更ではなく新しい値を生成して渡す必要がある。

オブジェクト型の更新

オブジェクト型の状態を更新する場合は、スプレッド構文を使用して既存のプロパティをコピーしつつ、変更するプロパティのみを上書きする。

  • スプレッド構文による更新
    既存オブジェクトをスプレッドし、変更したいプロパティだけを上書きする。
    直接変更すると再レンダリングが発生しない。
     import { useState } from 'react';
     
     interface FormData {
        firstName: string;
        lastName: string;
        email: string;
     }
     
     function ProfileForm() {
        const [form, setForm] = useState<FormData>({
           firstName: '',
           lastName: '',
           email: '',
        });
     
        const handleChange = (field: keyof FormData) => (
           e: React.ChangeEvent<HTMLInputElement>
        ) => {
           // スプレッド構文で既存のプロパティを保持しつつ更新
           setForm({ ...form, [field]: e.target.value });
     
           // 誤り : 直接変更では再レンダリングされない
           // form.firstName = e.target.value;
        };
     
        return (
           <form>
              <input value={form.firstName} onChange={handleChange('firstName')} placeholder="名" />
              <input value={form.lastName} onChange={handleChange('lastName')} placeholder="姓" />
              <input value={form.email} onChange={handleChange('email')} placeholder="メール" />
           </form>
        );
     }
    

  • ネストされたオブジェクトの更新
    ネストされたオブジェクトを更新する場合は、各レベルでスプレッド構文を適用する必要がある。
     import { useState } from 'react';
     
     interface Address {
        city: string;
        postalCode: string;
     }
     
     interface Person {
        name: string;
        address: Address;
     }
     
     function PersonForm() {
        const [person, setPerson] = useState<Person>({
           name: '田中 太郎',
           address: {
              city: '東京',
              postalCode: '100-0001',
           },
        });
     
        const updateCity = (city: string) => {
           // ネストされたオブジェクトも各レベルでスプレッドが必要
           setPerson({
              ...person,
              address: {
                 ...person.address,
                 city,
              },
           });
        };
     
        return (
           <div>
              <p>{person.name} - {person.address.city}</p>
              <button onClick={() => updateCity('大阪')}>大阪に変更</button>
           </div>
        );
     }
    


配列型の操作

配列型の状態を操作する場合も、元の配列を直接変更するメソッド (pushsplice 等) は使用せず、新しい配列を生成して渡す。

 import { useState } from 'react';
 
 interface Todo {
    id: number;
    text: string;
    completed: boolean;
 }
 
 function TodoList() {
    const [todos, setTodos] = useState<Todo[]>([]);
    const [inputText, setInputText] = useState<string>('');
 
    // 追加 : スプレッド構文で新しい要素を末尾に追加
    const addTodo = () => {
       if (!inputText.trim()) return;
       const newTodo: Todo = { id: Date.now(), text: inputText, completed: false };
       setTodos([...todos, newTodo]);
       setInputText('');
    };
 
    // 削除 : filter で対象以外の要素を残す
    const deleteTodo = (id: number) => {
       setTodos(todos.filter((t) => t.id !== id));
    };
 
    // 更新 : map で対象要素だけ変更した新しい配列を生成
    const toggleTodo = (id: number) => {
       setTodos(todos.map((t) =>
          t.id === id ? { ...t, completed: !t.completed } : t
       ));
    };
 
    return (
       <div>
          <input value={inputText} onChange={(e) => setInputText(e.target.value)} />
          <button onClick={addTodo}>追加</button>
          <ul>
             {todos.map((todo) => (
                <li key={todo.id}>
                   <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                      {todo.text}
                   </span>
                   <button onClick={() => toggleTodo(todo.id)}>完了切替</button>
                   <button onClick={() => deleteTodo(todo.id)}>削除</button>
                </li>
             ))}
          </ul>
       </div>
    );
 }



関数型更新

状態更新関数には、新しい値を直接渡す方法前の状態を受け取る関数を渡す方法 の2種類がある。

連続した更新や非同期処理の後の更新では、関数型更新パターンを使用することで正確な動作を保証できる。

prevState を使った更新パターン

setState(prevState => newState) の形式で関数を渡すと、Reactは常に最新の状態を prevState として関数に渡す。

 import { useState } from 'react';
 
 function Counter() {
    const [count, setCount] = useState<number>(0);
 
    // 値を直接渡す方法 : 現在のレンダリング時点の count を参照する
    const incrementDirect = () => setCount(count + 1);
 
    // 関数型更新 : 常に最新の状態 prevCount を参照する
    const incrementFunctional = () => setCount((prevCount) => prevCount + 1);
 
    // 連続した更新での違い
    const incrementThreeTimes = () => {
       // 直接渡す方法 : 3回呼び出しても count は 1 しか増加しない
       // setCount(count + 1);
       // setCount(count + 1);
       // setCount(count + 1);
 
       // 関数型更新 : 3回分正確に増加する
       setCount((p) => p + 1);
       setCount((p) => p + 1);
       setCount((p) => p + 1);
    };
 
    return (
       <div>
          <p>カウント: {count}</p>
          <button onClick={incrementDirect}>+1 (直接)</button>
          <button onClick={incrementFunctional}>+1 (関数型)</button>
          <button onClick={incrementThreeTimes}>+3 (関数型)</button>
       </div>
    );
 }


スナップショットとしてのstate

Reactにおけるstateは、各レンダリングにおけるスナップショットである。

setState を呼び出しても、現在のレンダリングコンテキスト内では状態値は変わらない。

 import { useState } from 'react';
 
 function SnapshotExample() {
    const [count, setCount] = useState<number>(0);
 
    const handleClick = () => {
       setCount(count + 1);
       // この時点で、countはまだ0 (次のレンダリングで更新される)
       console.log(count);  // 0が出力される
    };
 
    const handleAsyncClick = async () => {
       setCount(count + 1);
       // 非同期処理の後も、このクロージャ内のcountは変わらない
       await new Promise((resolve) => setTimeout(resolve, 1000));
       console.log(count);  // 更新前の値が出力される
 
       // 最新の状態が必要な場合は関数型更新を使用する
       setCount((prevCount) => prevCount + 1);
    };
 
    return (
       <div>
          <p>カウント: {count}</p>
          <button onClick={handleClick}>増加</button>
          <button onClick={handleAsyncClick}>非同期増加</button>
       </div>
    );
 }



遅延初期化

useState の初期値としてコストのかかる計算が必要な場合、関数を渡す遅延初期化パターンを使用できる。
初期化関数は初回レンダリング時にのみ実行され、再レンダリング時には無視される。

遅延初期化の基本パターン

useState(createInitialState) のように関数参照を渡す。
useState(createInitialState()) のように関数を呼び出してしまうと、毎回のレンダリングで実行されてしまうため注意が必要である。

 import { useState } from 'react';
 
 interface Todo {
    id: number;
    text: string;
    completed: boolean;
 }
 
 // コストの掛かる初期化処理
 function createInitialTodos(): Todo[] {
    console.log('初回のみ実行される');
    return Array.from({ length: 50 }, (_, i) => ({
       id: i,
       text: `タスク ${i + 1}`,
       completed: false,
    }));
 }
 
 function TodoApp() {
    // 関数参照を渡す (呼び出しではない)
    // 正しい : useState(createInitialTodos)
    // 誤り : useState(createInitialTodos()) → 毎回のレンダリングで実行される
    const [todos, setTodos] = useState<Todo[]>(createInitialTodos);
 
    return <p>タスク数: {todos.length}</p>;
 }


ローカルストレージからの初期化

ローカルストレージから値を読み取る時にも、遅延初期化パターンが有効である。

 import { useState } from 'react';
 
 function ThemeSelector() {
    // ローカルストレージの読み取りも遅延初期化で行う
    const [theme, setTheme] = useState<string>(() => {
       return localStorage.getItem('theme') ?? 'light';
    });
 
    const toggleTheme = () => {
       const newTheme = theme === 'light' ? 'dark' : 'light';
       setTheme(newTheme);
       localStorage.setItem('theme', newTheme);
    };
 
    return (
       <div>
          <p>現在のテーマ: {theme}</p>
          <button onClick={toggleTheme}>テーマ切替</button>
       </div>
    );
 }



バッチ更新

Reactは複数の状態更新をまとめて処理する バッチ更新 の仕組みを持つ。
React 17 と React 18でバッチ更新の対象範囲が異なるため、動作の違いを理解することが重要である。

React 18の自動バッチ処理

React 18以降では、全ての状態更新が自動的にバッチ処理される。
イベントハンドラ内だけでなく、setTimeoutPromisefetch のコールバック内での更新も1回の再レンダリングにまとめられる。

 import { useState } from 'react';
 
 function BatchUpdateExample() {
    const [count, setCount] = useState<number>(0);
    const [flag, setFlag] = useState<boolean>(false);
 
    // React 17 : イベントハンドラ内のみバッチ処理 → 1回の再レンダリング
    // React 18 : 同様に 1回の再レンダリング (変化なし)
    const handleClick = () => {
       setCount((c) => c + 1);
       setFlag((f) => !f);
       // 2つの更新は 1回の再レンダリングにまとめられる
    };
 
    // React 17 : setTimeout 内ではバッチ処理されない → 2回の再レンダリング
    // React 18 : 自動バッチ処理により → 1回の再レンダリング
    const handleAsyncClick = () => {
       setTimeout(() => {
          setCount((c) => c + 1);
          setFlag((f) => !f);
       }, 100);
    };
 
    // React 17 : Promise コールバック内ではバッチ処理されない → 2回の再レンダリング
    // React 18 : 自動バッチ処理により → 1回の再レンダリング
    const handleFetchClick = async () => {
       await fetch('/api/data');
       setCount((c) => c + 1);
       setFlag((f) => !f);
    };
 
    return (
       <div>
          <p>カウント: {count}, フラグ: {String(flag)}</p>
          <button onClick={handleClick}>同期更新</button>
          <button onClick={handleAsyncClick}>タイムアウト更新</button>
          <button onClick={handleFetchClick}>非同期更新</button>
       </div>
    );
 }


下表に、React 17 と React 18のバッチ更新の対象範囲の違いを示す。

バッチ更新の対象範囲
更新のタイミング React 17 React 18
イベントハンドラ内 バッチ処理される バッチ処理される
setTimeout コールバック内 バッチ処理されない バッチ処理される
Promise コールバック内 バッチ処理されない バッチ処理される
fetch コールバック内 バッチ処理されない バッチ処理される
ネイティブイベントハンドラ内 バッチ処理されない バッチ処理される



よくある間違い

useState を使用する時に陥りやすい間違いを示す。

状態の直接変更

状態として管理しているオブジェクトや配列を直接変更しても、Reactは変更を検知できず再レンダリングが発生しない。
必ず新しい値を生成して setState に渡す必要がある。

 import { useState } from 'react';
 
 function WrongMutation() {
    const [count, setCount] = useState<number>(0);
    const [items, setItems] = useState<string[]>(['a', 'b', 'c']);
 
    // 誤り : プリミティブの直接変更 (count++ は再レンダリングされない)
    // count++;
 
    // 正しい : setState で新しい値を渡す
    const incrementCount = () => setCount(count + 1);
 
    // 誤り : 配列を直接変更 (push は再レンダリングされない)
    // items.push('d');
 
    // 正しい : 新しい配列を生成して渡す
    const addItem = () => setItems([...items, 'd']);
 
    return (
       <div>
          <p>カウント: {count}</p>
          <button onClick={incrementCount}>増加</button>
          <button onClick={addItem}>追加</button>
          <ul>{items.map((item) => <li key={item}>{item}</li>)}</ul>
       </div>
    );
 }


無限ループ

状態更新関数をレンダリング中に直接呼び出すと、無限ループが発生する。
イベントハンドラ内でのみ状態を更新するようにする必要がある。

 import { useState } from 'react';
 
 function InfiniteLoopExample() {
    const [count, setCount] = useState<number>(0);
 
    return (
       <div>
          <p>カウント: {count}</p>
 
          {/* 誤り : レンダリング時に setCount が直接呼び出され無限ループが発生する */}
          {/* <button onClick={setCount(count + 1)}>増加</button> */}
 
          {/* 正しい : アロー関数でラップしてイベント時のみ実行する */}
          <button onClick={() => setCount(count + 1)}>増加</button>
       </div>
    );
 }


非同期でのstate参照

setState を呼び出した後も、現在のレンダリングのクロージャ内では古い状態値が参照される。
非同期処理内で最新の状態が必要な場合は、関数型更新パターンを使用する。

 import { useState } from 'react';
 
 function AsyncStateExample() {
    const [count, setCount] = useState<number>(0);
 
    const handleAsync = async () => {
       setCount(count + 1);
 
       // 誤り : 非同期処理後も count は更新前の値を参照している
       await new Promise((resolve) => setTimeout(resolve, 1000));
       // setCount(count + 1); → 2回目の更新が 1 回目の結果に基づかない可能性がある
 
       // 正しい : 関数型更新で常に最新の状態を参照する
       setCount((prevCount) => prevCount + 1);
    };
 
    return (
       <div>
          <p>カウント: {count}</p>
          <button onClick={handleAsync}>非同期増加</button>
       </div>
    );
 }



関連情報