第9回
stateとuseState:クリックで選択キャラを切り替える
useStateでキャラクター選択状態を管理する実装を通じて、stateの概念・更新関数・再レンダリングの仕組みを対話形式で解説。
·21分で読める
たける
登場人物ページ、りこさんのカードをクリックしたら詳細が表示されるようにしたくて。こう書いたんですけど動かなくて。
function CharactersPage() {
let selectedId = 'riko' // クリックしたら変えようとしている
return (
<div>
{characters.map(c => (
<button key={c.id} onClick={() => { selectedId = c.id }}>
{c.name}
</button>
))}
<CharacterDetail id={selectedId} />
</div>
)
}
りこ
`selectedId` は変わってる。でも画面は変わらない。なぜかわかる?
たける
変数が変わったのにReactが気づいてないから?
りこ
正確。Reactは普通の変数の変化を監視していない。画面を更新したいなら state を使う。
stateとは
state(状態) はReactが管理する変数。普通の変数と違い、stateが変わるとReactは自動でコンポーネントを再実行して、最新の値で画面を描き直す。
| 普通の変数 | state |
|---|---|
| 変わってもReactに伝わらない | 変わるとReactが検知する |
| 画面は更新されない | 画面が自動で更新される |
useStateの使い方
import { useState } from 'react'
function CharactersPage() {
// ① stateを宣言する
const [selectedId, setSelectedId] = useState('riko')
// ^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
// 現在の値 更新関数 初期値
const selected = characters.find(c => c.id === selectedId)!
return (
<div>
{characters.map(c => (
<button
key={c.id}
onClick={() => setSelectedId(c.id)} // ② 更新関数を呼ぶ
>
{c.name}
</button>
))}
{selected && <CharacterDetail character={selected} />}
</div>
)
}
たける
`const [selectedId, setSelectedId]` って配列みたいな書き方ですね。
りこ
分割代入。`useState` は `[現在の値, 更新関数]` の配列を返す。名前は自由につけていい。慣例で `[xxx, setXxx]` と名付ける。
たける
`setSelectedId(c.id)` を呼ぶと何が起きるんですか?
りこ
stateが新しい値に更新されて、Reactがコンポーネントを最初から再実行する。今度は `selectedId` が新しい値になっているので、`selected` も変わって、画面も変わる。
再レンダリングの流れ
- ボタンがクリックされる
setSelectedId('takeru')が呼ばれる- Reactが
CharactersPageを再実行する const [selectedId, ...] = useState('riko')─ 初回以降は'takeru'が返るselectedが{ id: 'takeru', name: '宮本たける', ... }になる- 画面が新しい
selectedの内容で描き直される
たける
関数が再実行されるって、`useState('riko')` も毎回呼ばれませんか? 初期値に戻っちゃわないですか?
りこ
いい気づき。`useState` の初期値は最初のレンダリングだけに使われる。2回目以降はReactが内部で持っている値を返す。`'riko'` はただの「スタート地点」。
stateに何を持たせるか
今回は「選択中のキャラクターのID」をstateにした。「選択中のキャラクターオブジェクト全体」にしなかった理由がある。
// ❌ オブジェクト全体をstateにする
const [selectedCharacter, setSelectedCharacter] = useState(characters[0])
// ✅ IDだけをstateにして、オブジェクトは導出する
const [selectedId, setSelectedId] = useState('riko')
const selected = characters.find(c => c.id === selectedId)IDだけにすることで:
- stateがシンプルになる
characters配列が更新されてもselectedが自動で追随する- シリアライズ(URLパラメータや localStorage への保存)が簡単
「stateの最小化」 ─ stateには「これがなければ導出できない情報」だけを持たせる。
ユナ
「IDだけstateにしてオブジェクトは導出する」というのはデータベース設計の正規化と同じ考え方。マスターデータから派生できる値を別カラムに持たない、という原則がそのままフロントエンドのstateに使えている。
たける
ユナさんの説明でDB設計と繋がった。フロントエンドの話なのにバックエンドの言葉で腑に落ちた。
ユナ
設計の原則は層が違っても同じことが多い。フロントとバックを両方触ると、そういうパターンが見えてくる。
画像ギャラリーにも応用する
同じ仕組みで、キャラクターカード内の画像ギャラリーも実装できる。
const CharacterCard = ({ character: c }: { character: Character }) => {
const [selectedIndex, setSelectedIndex] = useState(0) // 選択中の画像番号
const allImages = c.images ?? [c.image]
return (
<div>
{/* 大きい画像 */}
<img src={allImages[selectedIndex]} alt={c.name} />
{/* サムネイル */}
{allImages.map((src, i) => (
<button key={i} onClick={() => setSelectedIndex(i)}>
<img src={src} alt={`${c.name} ${i + 1}`} />
</button>
))}
</div>
)
}
たける
サムネイルを押したら大きい画像が変わった! これ、jQueryで書いたら `$('#main-img').attr('src', src)` とか書かないといけないですよね。Reactは「どのインデックスか」を管理するだけでいいのか。
りこ
そこがReactの宣言的UIの強みが出る場所。「今のインデックスではこのURLを表示する」と書くだけで、切り替えの実装はReactが引き受ける。
📁 第9回完了時点のファイル構成・完成コード
フォルダ構成
src/
├── components/
│ ├── Header.tsx
│ ├── Footer.tsx
│ └── CharacterCard.tsx ← 今回追加
├── pages/
│ └── CharactersPage.tsx ← 今回追加
└── App.tsxCharacterCard.tsx
import { useState } from 'react'
type Character = {
id: string
name: string
role: string
age: number
description: string
image?: string
images?: string[]
accentColor: string
}
export const CharacterCard = ({ character: c }: { character: Character }) => {
const allImages = c.images ?? (c.image ? [c.image] : [])
const [selectedIndex, setSelectedIndex] = useState(0)
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
{allImages.length > 0 && (
<div className="mb-4">
<img
src={allImages[selectedIndex]}
alt={c.name}
className="w-full h-48 object-cover rounded-xl"
/>
{allImages.length > 1 && (
<div className="flex gap-2 mt-2">
{allImages.map((src, i) => (
<button
key={i}
onClick={() => setSelectedIndex(i)}
className="w-12 h-12 rounded-lg overflow-hidden border-2 transition-colors"
style={{ borderColor: i === selectedIndex ? c.accentColor : '#e2e8f0' }}
>
<img src={src} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
)}
<h2 className="text-xl font-bold text-slate-900">{c.name}</h2>
<p className="text-sm text-slate-500 mt-1">{c.role}・{c.age}歳</p>
<p className="text-slate-600 text-sm mt-3 leading-7">{c.description}</p>
</div>
)
}CharactersPage.tsx
import { useState } from 'react'
import { CharacterCard } from '../components/CharacterCard'
const characters = [
{
id: 'riko',
name: '大沢りこ',
role: 'シニアエンジニア',
age: 39,
description: 'フルスタックエンジニア。コードで解決できることはコードで解決するタイプ。',
images: ['/characters/riko.jpg', '/characters/riko-bust.jpg'],
accentColor: '#0ea5e9',
},
{
id: 'takeru',
name: '宮本たける',
role: 'インターン',
age: 26,
description: '文系出身の第二新卒エンジニア。Reactを学び中。',
image: '/characters/takeru.jpg',
accentColor: '#8b5cf6',
},
]
export const CharactersPage = () => {
const [selectedId, setSelectedId] = useState(characters[0].id)
const selected = characters.find(c => c.id === selectedId)!
return (
<div className="max-w-5xl mx-auto px-4 sm:px-8 py-12">
<h1 className="text-3xl font-bold text-slate-900 mb-8">登場人物</h1>
<div className="flex gap-3 mb-8">
{characters.map(c => (
<button
key={c.id}
onClick={() => setSelectedId(c.id)}
className={`px-4 py-2 rounded-full text-sm font-semibold transition-colors ${
c.id === selectedId
? 'bg-sky-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{c.name}
</button>
))}
</div>
<CharacterCard character={selected} />
</div>
)
}まとめ
- state はReactが管理する変数。変わると自動で再レンダリングが起きる
const [value, setValue] = useState(初期値)で宣言する- stateの更新は必ず
setValue()経由。直接書き換えても画面は変わらない - stateは最小化する。「導出できる値」はstateにしない
次の第10回では、クリック以外のイベント(入力・ホバーなど)を扱う。