はじめに

前回の記事では、Propsを使ってコンポーネント間でデータを受け渡す方法を解説しました。本記事では、Reactにおける状態管理の基本である「useState」フックについて詳しく解説します。

useStateは、コンポーネント内でデータを保持し、そのデータが変更されたときにUIを自動的に更新するための仕組みです。ボタンのクリック回数、フォームの入力値、表示/非表示の切り替えなど、ユーザー操作に応じて変化するデータを管理するために使用します。

本記事を読むことで、以下のことができるようになります。

  • useStateの基本的な使い方の習得
  • プリミティブ型(数値・文字列・真偽値)の状態管理
  • 配列とオブジェクトの状態管理
  • 更新関数の正しい使い方
  • よくある間違いとその解決策

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • Viteで作成したReactプロジェクト(TypeScript推奨)
  • VS Code(推奨)

前提知識

  • 関数コンポーネントの基本
  • Propsの使い方
  • JavaScriptの配列とオブジェクトの操作

状態(State)とは何か

Reactにおける「状態(State)」は、コンポーネントが保持する動的なデータです。Propsが親コンポーネントから渡される読み取り専用のデータであるのに対し、Stateはコンポーネント自身が管理し、変更できるデータです。

PropsとStateの違い

項目 Props State
データの出所 親コンポーネント コンポーネント自身
変更可否 読み取り専用 変更可能
用途 外部からの設定値 内部で管理する動的データ
ボタンのラベル クリック回数

なぜStateが必要なのか

通常のJavaScript変数では、値を変更してもReactがUIを再描画しません。次の例を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// これは正しく動作しない例
function Counter() {
  let count = 0;
  
  function handleClick() {
    count = count + 1;
    console.log(count); // コンソールには増加した値が表示される
  }
  
  return (
    <div>
      <p>カウント: {count}</p> {/* 常に0のまま */}
      <button onClick={handleClick}>増加</button>
    </div>
  );
}

このコードでは、ボタンをクリックしても画面の数値は更新されません。Reactがコンポーネントを再レンダリングするトリガーがないためです。useStateを使うことで、値の変更を検知してUIを自動的に更新できます。

useStateの基本構文

useStateは、Reactが提供するフック(Hook)の一つです。以下が基本的な構文です。

1
2
3
4
5
6
import { useState } from 'react';

function MyComponent() {
  const [state, setState] = useState(initialValue);
  // ...
}

構文の解説

  • useState(initialValue): 初期値を引数に取り、配列を返す
  • state: 現在の状態値
  • setState: 状態を更新するための関数
  • initialValue: 状態の初期値(任意の型)

配列の分割代入

useStateは2つの要素を持つ配列を返します。配列の分割代入を使って、それぞれの要素に名前を付けて取り出します。

1
2
3
4
5
6
7
// useStateの戻り値を分割代入で受け取る
const [count, setCount] = useState(0);

// 上記は以下と同等
const stateArray = useState(0);
const count = stateArray[0];    // 現在の値
const setCount = stateArray[1]; // 更新関数

慣例として、状態変数名がsomethingの場合、更新関数はsetSomethingと命名します。

数値の状態管理

最もシンプルなuseStateの例として、カウンターを実装してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  function handleIncrement() {
    setCount(count + 1);
  }
  
  function handleDecrement() {
    setCount(count - 1);
  }
  
  function handleReset() {
    setCount(0);
  }
  
  return (
    <div>
      <h2>カウンター</h2>
      <p>現在の値: {count}</p>
      <button onClick={handleIncrement}>+1</button>
      <button onClick={handleDecrement}>-1</button>
      <button onClick={handleReset}>リセット</button>
    </div>
  );
}

export default Counter;

期待される結果

ブラウザで確認すると、以下の動作が確認できます。

  • 「+1」ボタンをクリックするとカウントが1増加
  • 「-1」ボタンをクリックするとカウントが1減少
  • 「リセット」ボタンをクリックするとカウントが0に戻る

オンラインで試す

このカウンターの動作は、以下のリンクで実際に試すことができます。

文字列の状態管理

テキスト入力フィールドの値を管理する例を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState } from 'react';

function NameInput() {
  const [name, setName] = useState('');
  
  function handleChange(event) {
    setName(event.target.value);
  }
  
  return (
    <div>
      <h2>名前入力</h2>
      <input
        type="text"
        value={name}
        onChange={handleChange}
        placeholder="お名前を入力してください"
      />
      <p>こんにちは{name || 'ゲスト'}さん!</p>
    </div>
  );
}

export default NameInput;

制御コンポーネントについて

上記の例では、value={name}でinput要素の値をStateと紐付けています。これを「制御コンポーネント」と呼びます。入力値がReactのStateによって完全に制御されるため、値の検証や変換が容易になります。

期待される結果

  • テキストを入力すると、リアルタイムで挨拶文が更新される
  • 入力が空の場合は「ゲスト」と表示される

真偽値の状態管理

表示/非表示の切り替えなど、真偽値を使った状態管理の例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useState } from 'react';

function ToggleContent() {
  const [isVisible, setIsVisible] = useState(false);
  
  function handleToggle() {
    setIsVisible(!isVisible);
  }
  
  return (
    <div>
      <h2>コンテンツ表示切り替え</h2>
      <button onClick={handleToggle}>
        {isVisible ? '非表示にする' : '表示する'}
      </button>
      {isVisible && (
        <div style={{ marginTop: '16px', padding: '16px', backgroundColor: '#f0f0f0' }}>
          <p>これは切り替え可能なコンテンツです</p>
          <p>ボタンをクリックすると表示/非表示が切り替わります</p>
        </div>
      )}
    </div>
  );
}

export default ToggleContent;

期待される結果

  • 初期状態ではコンテンツは非表示
  • 「表示する」ボタンをクリックするとコンテンツが表示される
  • 「非表示にする」ボタンをクリックするとコンテンツが隠れる

複数のStateを使う

1つのコンポーネントで複数のStateを管理することも一般的です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { useState } from 'react';

function UserForm() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [age, setAge] = useState(0);
  
  return (
    <div>
      <h2>ユーザー情報入力</h2>
      <div>
        <label>
          :
          <input
            type="text"
            value={lastName}
            onChange={(e) => setLastName(e.target.value)}
          />
        </label>
      </div>
      <div>
        <label>
          :
          <input
            type="text"
            value={firstName}
            onChange={(e) => setFirstName(e.target.value)}
          />
        </label>
      </div>
      <div>
        <label>
          年齢:
          <input
            type="number"
            value={age}
            onChange={(e) => setAge(Number(e.target.value))}
          />
        </label>
      </div>
      <p>
        {lastName} {firstName}さん{age}
      </p>
    </div>
  );
}

export default UserForm;

関連するデータが複数ある場合は、オブジェクトとしてまとめて管理することも検討してください(後述)。

オブジェクトの状態管理

フォームデータなど、関連する複数の値をオブジェクトとしてまとめて管理する方法です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useState } from 'react';

function ProfileForm() {
  const [profile, setProfile] = useState({
    name: '',
    email: '',
    bio: ''
  });
  
  function handleChange(event) {
    const { name, value } = event.target;
    setProfile({
      ...profile,      // 既存のプロパティを展開
      [name]: value    // 変更するプロパティを上書き
    });
  }
  
  return (
    <div>
      <h2>プロフィール編集</h2>
      <div>
        <label>
          名前:
          <input
            type="text"
            name="name"
            value={profile.name}
            onChange={handleChange}
          />
        </label>
      </div>
      <div>
        <label>
          メール:
          <input
            type="email"
            name="email"
            value={profile.email}
            onChange={handleChange}
          />
        </label>
      </div>
      <div>
        <label>
          自己紹介:
          <textarea
            name="bio"
            value={profile.bio}
            onChange={handleChange}
          />
        </label>
      </div>
      <h3>プレビュー</h3>
      <p>名前: {profile.name}</p>
      <p>メール: {profile.email}</p>
      <p>自己紹介: {profile.bio}</p>
    </div>
  );
}

export default ProfileForm;

スプレッド構文の重要性

オブジェクトを更新する際は、必ず新しいオブジェクトを作成します。既存のオブジェクトを直接変更(ミューテート)してはいけません。

1
2
3
4
5
6
7
8
9
// 間違った方法(ミューテーション)
profile.name = 'Taro';
setProfile(profile); // 更新されない!

// 正しい方法(新しいオブジェクトを作成)
setProfile({
  ...profile,
  name: 'Taro'
});

ReactはObject.is()で前後の状態を比較するため、同じオブジェクト参照を渡すと変更がないと判断され、再レンダリングが行われません。

配列の状態管理

ToDoリストなど、配列データを管理する例を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import { useState } from 'react';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [inputValue, setInputValue] = useState('');
  
  function handleAddTodo() {
    if (inputValue.trim() === '') return;
    
    const newTodo = {
      id: Date.now(),
      text: inputValue,
      completed: false
    };
    
    // 新しい配列を作成して追加
    setTodos([...todos, newTodo]);
    setInputValue('');
  }
  
  function handleToggle(id) {
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  }
  
  function handleDelete(id) {
    setTodos(todos.filter(todo => todo.id !== id));
  }
  
  return (
    <div>
      <h2>ToDoリスト</h2>
      <div>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="タスクを入力"
          onKeyPress={(e) => e.key === 'Enter' && handleAddTodo()}
        />
        <button onClick={handleAddTodo}>追加</button>
      </div>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggle(todo.id)}
            />
            <span style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
              marginLeft: '8px'
            }}>
              {todo.text}
            </span>
            <button
              onClick={() => handleDelete(todo.id)}
              style={{ marginLeft: '8px' }}
            >
              削除
            </button>
          </li>
        ))}
      </ul>
      <p>合計: {todos.length} / 完了: {todos.filter(t => t.completed).length}</p>
    </div>
  );
}

export default TodoList;

配列操作のパターン

配列の状態を更新する際によく使うパターンをまとめます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 要素を追加(末尾)
setItems([...items, newItem]);

// 要素を追加(先頭)
setItems([newItem, ...items]);

// 要素を削除
setItems(items.filter(item => item.id !== targetId));

// 要素を更新
setItems(items.map(item =>
  item.id === targetId ? { ...item, ...updates } : item
));

// 要素を並び替え
setItems([...items].sort((a, b) => a.order - b.order));

配列を直接変更するメソッド(pushsplicesortなど)は使わず、新しい配列を返すメソッド(mapfilterconcatなど)を使用します。

更新関数による状態更新

状態を更新する際、現在の状態に基づいて新しい状態を計算する場合は、更新関数(updater function)を使用することを推奨します。

更新関数を使うべき理由

次の例を見てください。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function Counter() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    // 3回呼んでも1しか増えない!
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }
  
  return (
    <button onClick={handleClick}>
      +3カウント: {count}
    </button>
  );
}

ボタンをクリックしても、カウントは3ではなく1しか増えません。これは、イベントハンドラ内でのcountは常に同じ値(レンダリング時点の値)を参照するためです。

更新関数を使った解決策

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function Counter() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    // 更新関数を使うと正しく3増える
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
  }
  
  return (
    <button onClick={handleClick}>
      +3カウント: {count}
    </button>
  );
}

更新関数prevCount => prevCount + 1は、Reactの更新キューに追加され、順番に実行されます。

キュー内の更新関数 前の状態 新しい状態
prevCount => prevCount + 1 0 1
prevCount => prevCount + 1 1 2
prevCount => prevCount + 1 2 3

更新関数の命名規則

慣例として、更新関数の引数には以下のような名前を使用します。

1
2
3
setCount(c => c + 1);           // 状態名の頭文字
setCount(prev => prev + 1);     // prev接頭辞
setCount(prevCount => prevCount + 1); // prev + 状態名

初期化関数による遅延初期化

初期値の計算にコストがかかる場合、初期化関数を使って遅延初期化ができます。

1
2
3
4
5
// 毎回のレンダリングで関数が呼ばれる(非効率)
const [todos, setTodos] = useState(createInitialTodos());

// 初回のレンダリングでのみ関数が呼ばれる(効率的)
const [todos, setTodos] = useState(createInitialTodos);

関数を呼び出す()を付けずに関数自体を渡すことで、Reactは初回レンダリング時にのみその関数を実行します。

1
2
3
4
5
6
7
8
9
function TodoList() {
  // 初期化関数を使った例
  const [todos, setTodos] = useState(() => {
    const savedTodos = localStorage.getItem('todos');
    return savedTodos ? JSON.parse(savedTodos) : [];
  });
  
  // ...
}

よくある間違いと解決策

useStateを使う際によくある間違いと、その解決策を紹介します。

間違い1: 更新後すぐに新しい値を参照しようとする

1
2
3
4
5
6
7
8
function Counter() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    setCount(count + 1);
    console.log(count); // 0のまま!新しい値ではない
  }
}

状態の更新は非同期的に処理されるため、setCountを呼んだ直後にcountを参照しても、まだ古い値です。

解決策: 新しい値を変数に保存してから使用する。

1
2
3
4
5
function handleClick() {
  const newCount = count + 1;
  setCount(newCount);
  console.log(newCount); // 正しく1が表示される
}

間違い2: オブジェクトや配列を直接変更する

1
2
3
4
5
// 間違い
function handleClick() {
  profile.name = 'Taro';  // 直接変更
  setProfile(profile);    // 同じ参照なので更新されない
}

解決策: 新しいオブジェクト/配列を作成する。

1
2
3
function handleClick() {
  setProfile({ ...profile, name: 'Taro' });
}

間違い3: レンダリング中に状態を更新する

1
2
3
4
5
6
// 無限ループになる!
function Counter() {
  const [count, setCount] = useState(0);
  setCount(count + 1); // レンダリング中に呼び出してはいけない
  return <p>{count}</p>;
}

解決策: 状態の更新はイベントハンドラやuseEffect内で行う。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function Counter() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    setCount(count + 1); // イベントハンドラ内で更新
  }
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>増加</button>
    </div>
  );
}

間違い4: Hookをループや条件分岐の中で使う

1
2
3
4
5
6
7
8
// 間違い
function Counter({ showExtra }) {
  const [count, setCount] = useState(0);
  
  if (showExtra) {
    const [extra, setExtra] = useState(0); // 条件付きでHookを呼んではいけない
  }
}

Hooksは常に同じ順序で呼び出される必要があります。

解決策: Hookはコンポーネントのトップレベルで呼び出す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function Counter({ showExtra }) {
  const [count, setCount] = useState(0);
  const [extra, setExtra] = useState(0); // 常に呼び出す
  
  return (
    <div>
      <p>カウント: {count}</p>
      {showExtra && <p>追加: {extra}</p>}
    </div>
  );
}

TypeScriptでの型定義

TypeScriptを使用する場合、useStateに型を指定することで型安全性が向上します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// プリミティブ型(自動推論)
const [count, setCount] = useState(0);        // number型と推論
const [name, setName] = useState('');          // string型と推論
const [isOpen, setIsOpen] = useState(false);   // boolean型と推論

// 明示的な型指定
const [count, setCount] = useState<number>(0);

// オブジェクト型
type User = {
  id: number;
  name: string;
  email: string;
};

const [user, setUser] = useState<User | null>(null);

// 配列型
type Todo = {
  id: number;
  text: string;
  completed: boolean;
};

const [todos, setTodos] = useState<Todo[]>([]);

初期値がnullや空配列[]の場合は、型を明示的に指定することを推奨します。

実践例: 簡易フォームバリデーション

学んだ内容を組み合わせた実践的な例として、リアルタイムバリデーション付きのフォームを作成します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import { useState } from 'react';

function RegistrationForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
    confirmPassword: ''
  });
  
  const [errors, setErrors] = useState({
    email: '',
    password: '',
    confirmPassword: ''
  });
  
  const [isSubmitted, setIsSubmitted] = useState(false);
  
  function validateEmail(email) {
    if (!email) return 'メールアドレスを入力してください';
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return '有効なメールアドレスを入力してください';
    }
    return '';
  }
  
  function validatePassword(password) {
    if (!password) return 'パスワードを入力してください';
    if (password.length < 8) return 'パスワードは8文字以上で入力してください';
    return '';
  }
  
  function validateConfirmPassword(password, confirmPassword) {
    if (!confirmPassword) return '確認用パスワードを入力してください';
    if (password !== confirmPassword) return 'パスワードが一致しません';
    return '';
  }
  
  function handleChange(e) {
    const { name, value } = e.target;
    
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
    
    // リアルタイムバリデーション
    let error = '';
    if (name === 'email') {
      error = validateEmail(value);
    } else if (name === 'password') {
      error = validatePassword(value);
    } else if (name === 'confirmPassword') {
      error = validateConfirmPassword(formData.password, value);
    }
    
    setErrors(prev => ({
      ...prev,
      [name]: error
    }));
  }
  
  function handleSubmit(e) {
    e.preventDefault();
    
    const emailError = validateEmail(formData.email);
    const passwordError = validatePassword(formData.password);
    const confirmPasswordError = validateConfirmPassword(
      formData.password,
      formData.confirmPassword
    );
    
    setErrors({
      email: emailError,
      password: passwordError,
      confirmPassword: confirmPasswordError
    });
    
    if (!emailError && !passwordError && !confirmPasswordError) {
      setIsSubmitted(true);
    }
  }
  
  if (isSubmitted) {
    return (
      <div>
        <h2>登録完了</h2>
        <p>ご登録ありがとうございます</p>
        <p>登録メールアドレス: {formData.email}</p>
      </div>
    );
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>ユーザー登録</h2>
      
      <div style={{ marginBottom: '16px' }}>
        <label>
          メールアドレス:
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            style={{ display: 'block', width: '100%', padding: '8px' }}
          />
        </label>
        {errors.email && (
          <p style={{ color: 'red', margin: '4px 0 0' }}>{errors.email}</p>
        )}
      </div>
      
      <div style={{ marginBottom: '16px' }}>
        <label>
          パスワード:
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            style={{ display: 'block', width: '100%', padding: '8px' }}
          />
        </label>
        {errors.password && (
          <p style={{ color: 'red', margin: '4px 0 0' }}>{errors.password}</p>
        )}
      </div>
      
      <div style={{ marginBottom: '16px' }}>
        <label>
          パスワード確認:
          <input
            type="password"
            name="confirmPassword"
            value={formData.confirmPassword}
            onChange={handleChange}
            style={{ display: 'block', width: '100%', padding: '8px' }}
          />
        </label>
        {errors.confirmPassword && (
          <p style={{ color: 'red', margin: '4px 0 0' }}>{errors.confirmPassword}</p>
        )}
      </div>
      
      <button type="submit" style={{ padding: '8px 24px' }}>
        登録する
      </button>
    </form>
  );
}

export default RegistrationForm;

オンラインで試す

このフォームバリデーションの例は、以下のリンクで動作を確認できます。

まとめ

本記事では、ReactのuseStateフックについて基礎から解説しました。

学んだこと

  • useStateの基本: const [state, setState] = useState(initialValue)の構文で状態を管理
  • プリミティブ型の管理: 数値、文字列、真偽値の状態更新
  • オブジェクトと配列の管理: スプレッド構文を使った不変性を保つ更新
  • 更新関数: setState(prev => newValue)による正確な状態更新
  • よくある間違い: ミューテーション、レンダリング中の更新、条件付きHooks呼び出しの回避

次のステップ

useStateの基本を理解したら、次はユーザーインタラクションをより詳しく学びましょう。次の記事では、クリック、入力、フォーム送信などのイベント処理について解説します。

また、複数のコンポーネント間で状態を共有する「状態のリフトアップ」や、副作用処理を扱う「useEffect」についても、この学習シリーズで順次解説していきます。

参考リンク