Next.js

Next.jsのuseState実践:カウンター・フォーム・状態更新の注意点

この記事でわかること

Next.jsでuseStateを使う実践入門。カウンターと入力フォームから、型推論、関数による更新、オブジェクトの更新、Pages RouterとApp Routerの違いを解説します。

useStateは、コンポーネントが画面の状態を記憶するためのReactのHookです。ボタンを押した回数やフォームの入力値など、操作に合わせて変わる値に使います。通常の変数へ代入するだけでは、Reactに画面の更新を依頼できません。

この記事はTypeScriptとPages Routerを前提にします。React 18・Next.js 13のプロジェクトで使える例です。App Routerで利用する場合の境界は最後に説明します。

カウンターで状態の更新を学ぶ

カウンターを作る

ファイル:src/components/Counter.tsx

import { useState } from 'react';

type CounterProps = { initialCount?: number };

export default function Counter({ initialCount = 0 }: CounterProps) {
  const [count, setCount] = useState(initialCount);

  function addThree() {
    setCount(current => current + 1);
    setCount(current => current + 1);
    setCount(current => current + 1);
  }

  return (
    <section>
      <p>回数: {count}</p>
      <button type="button" onClick={() => setCount(current => current + 1)}>
        1増やす
      </button>
      <button type="button" onClick={addThree}>3増やす</button>
      <button type="button" onClick={() => setCount(initialCount)}>リセット</button>
    </section>
  );
}

useState()からは現在の値と更新関数を受け取ります。初期値が数値なので、この例の状態はnumberと推論され、毎回useState<number>()と書く必要はありません。

前の値から次の値を計算するときは、current => current + 1のような更新関数を渡します。addThree()は保留中の更新を順に適用するため、3増えます。同じイベント内でsetCount(count + 1)を3回書くと、同じ描画時点のcountから計算した値を3回渡すことになるため、同じ意味にはなりません。

ページで表示する

ファイル:src/pages/counter.tsx

import Counter from '../components/Counter';

export default function CounterPage() {
  return (
    <main>
      <h1>状態を持つコンポーネント</h1>
      <Counter initialCount={10} />
    </main>
  );
}

npm run devを起動して/counterを開きます。最初に10が表示され、「1増やす」で11、「3増やす」で14、「リセット」で10に戻ることを確認できます。

initialCountは状態を作る初期値です。親からの値が後で変わっても、useState(initialCount)がそのたびに状態を初期化し直すわけではありません。親が値を管理する必要があるなら、状態を親へ移して値と更新用のコールバックを渡す設計も検討します。この例のリセットボタンは、クリックした時点のinitialCountへ明示的に戻します。

オブジェクトは新しい値を作って更新する

ファイル:src/components/ProfileEditor.tsx

import { useState } from 'react';

type Profile = { name: string; receivesUpdates: boolean };

export default function ProfileEditor() {
  const [profile, setProfile] = useState<Profile>({
    name: '',
    receivesUpdates: false,
  });

  return (
    <section>
      <label>
        表示名
        <input
          value={profile.name}
          onChange={event => {
            const name = event.currentTarget.value;
            setProfile(current => ({ ...current, name }));
          }}
        />
      </label>
      <label>
        <input
          type="checkbox"
          checked={profile.receivesUpdates}
          onChange={event => {
            const receivesUpdates = event.currentTarget.checked;
            setProfile(current => ({ ...current, receivesUpdates }));
          }}
        />
        更新のお知らせを希望する
      </label>
      <p>表示名: {profile.name || '未入力'}</p>
    </section>
  );
}

profile.name = ...のように元のオブジェクトを直接変更せず、新しいオブジェクトを渡しています。useStateの更新は、クラスコンポーネントのsetStateのようにオブジェクトの一部を自動で結合しません。残す項目を...currentでコピーします。ネストしたオブジェクトは、変更する階層まで新しい値を作る必要があります。

実行タイミングと型の注意点

  • 更新関数を呼んでも、実行中のイベントハンドラー内の状態変数はすぐには変わりません。次の描画で新しい値を受け取ります。
  • HookはコンポーネントまたはカスタムHookのトップレベルで呼び、条件分岐やループの中へ置きません。
  • 初期値だけでは必要な型にならない場合は、useState<string | null>(null)のように型引数で意図を示します。
  • 開発時のStrict Modeでは、初期化関数や更新関数が追加で実行されることがあります。これらの中で通信・ログ送信・外部配列の変更などを行わず、値を計算する処理にします。

App Routerで使用する場合

App Routerでは、このように状態やイベントを扱うコンポーネントをClient Componentの境界内へ置きます。たとえばCounter.tsxの先頭で、importより前に'use client';を指定します。すべての親ページをClient Componentにする必要はありません。

Client Componentも初回表示用にサーバーで描画されることがあります。useState(window.innerWidth)のように描画時に無条件でブラウザAPIを読む実装は避け、必要な同期をuseEffectへ分けます。

関連記事と公式資料

状態更新を確認するテスト

連続する操作を試す

一回のクリックだけでなく、連続更新やリセット後の再操作を確かめます。前のstateから次を計算する場合は、更新関数を渡す方法で、同じ描画内の更新を正しく扱えるか確認します。

外部の値と同期する必要を考える

propsを初期値に使っても、その後のprops変更が自動でstateに反映されるわけではありません。親で値を管理するのか、入力途中の値を子に保持するのかを先に決めます。

Next.jsとは・ルーターの違い・学習順を確認する

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →