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に反映されるわけではありません。親で値を管理するのか、入力途中の値を子に保持するのかを先に決めます。