こんにちは、かつコーチです。
前回は親子コンポーネント間でのデータの受け渡し方を解説しました。
useState自体は入門段階でも触れる機能ですが、文字列や数値ではなく「オブジェクト」や「配列」を扱い始めると、急につまずく人が多くなります。
今回は、実務でも頻出するオブジェクト・配列のstate更新パターンを丁寧に整理していきます。
useStateの基本のおさらい
state更新は「置き換え」であって「変更」ではない
useStateを理解するうえで一番大事な前提が、state更新は常に新しい値への「置き換え」であるという点です。
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
export default Counter;
数値や文字列のようなプリミティブな値であれば、この「置き換え」は自然に行われます。
しかしオブジェクトや配列のような「参照型」の値になると、話が少しややこしくなります。
なぜオブジェクト・配列は特別扱いが必要なのか
JavaScriptにおいて、オブジェクトや配列は参照でデータを持っています。
つまり、中身のプロパティを直接書き換えても、変数自体(参照)は変わっていないため、Reactは「値が変わった」と判断できません。
Reactはstateが変わったかどうかを、基本的に「前回と今回で参照が同じか違うか」で判定しています。
この仕組みを理解していないと、「stateを更新したはずなのに画面が再レンダリングされない」という現象にぶつかります。
オブジェクトのstateを更新する
新しいオブジェクトを作って置き換える
オブジェクトのstateを更新するときは、元のオブジェクトを直接書き換えるのではなく、新しいオブジェクトを作って丸ごと置き換えるのが基本です。
import { useState } from "react";
type UserProfile = {
name: string;
age: number;
};
function ProfileEditor() {
const [profile, setProfile] = useState<UserProfile>({
name: "かつコーチ",
age: 30,
});
const handleAgeUp = () => {
setProfile({ ...profile, age: profile.age + 1 });
};
return (
<div>
<p>{profile.name}({profile.age}歳)</p>
<button onClick={handleAgeUp}>年齢を+1する</button>
</div>
);
}
export default ProfileEditor;
{ ...profile, age: profile.age + 1 }という書き方は、スプレッド構文と呼ばれるもので、「profileの中身をすべてコピーしつつ、ageだけ新しい値で上書きする」という意味です。
これにより「新しいオブジェクト」が生成されるため、Reactは変更を正しく検知できます。
入れ子になったオブジェクトの更新
オブジェクトの中にさらにオブジェクトがある「入れ子」構造では、スプレッド構文を階層分だけ重ねる必要があります。
import { useState } from "react";
type Address = {
city: string;
zip: string;
};
type User = {
name: string;
address: Address;
};
function AddressEditor() {
const [user, setUser] = useState<User>({
name: "かつコーチ",
address: { city: "東京都", zip: "100-0001" },
});
const handleCityChange = (newCity: string) => {
setUser({
...user,
address: { ...user.address, city: newCity },
});
};
return (
<div>
<p>{user.address.city}</p>
<button onClick={() => handleCityChange("大阪府")}>大阪府に変更</button>
</div>
);
}
export default AddressEditor;
外側のuserだけでなく、内側のaddressもスプレッド構文でコピーしないと、意図した更新になりません。
配列のstateを更新する
要素を追加する
配列にも同じ考え方が適用されます。
pushのような「元の配列自体を書き換えるメソッド」ではなく、スプレッド構文で新しい配列を作るのが基本です。
import { useState } from "react";
function TodoList() {
const [todos, setTodos] = useState<string[]>(["買い物", "掃除"]);
const addTodo = (newTodo: string) => {
setTodos([...todos, newTodo]);
};
return (
<div>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
<button onClick={() => addTodo("洗濯")}>タスクを追加</button>
</div>
);
}
export default TodoList;
要素を削除する・更新する
削除にはfilter、特定要素の更新にはmapを組み合わせるのが定番のパターンです。
import { useState } from "react";
type Task = {
id: number;
text: string;
done: boolean;
};
function TaskList() {
const [tasks, setTasks] = useState<Task[]>([
{ id: 1, text: "資料作成", done: false },
{ id: 2, text: "メール返信", done: false },
]);
const removeTask = (id: number) => {
setTasks(tasks.filter((task) => task.id !== id));
};
const toggleDone = (id: number) => {
setTasks(
tasks.map((task) =>
task.id === id ? { ...task, done: !task.done } : task
)
);
};
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<span style={{ textDecoration: task.done ? "line-through" : "none" }}>
{task.text}
</span>
<button onClick={() => toggleDone(task.id)}>完了切替</button>
<button onClick={() => removeTask(task.id)}>削除</button>
</li>
))}
</ul>
);
}
export default TaskList;
filterは「条件に合う要素だけを残した新しい配列」を、mapは「各要素を変換した新しい配列」を返すため、どちらも元の配列は書き換えません。
この「元の配列に触らず、新しい配列を返す」という性質が、Reactのstate更新と非常に相性がよいのです。
よくあるつまずきポイント・エラー対処
つまずき:pushで配列を更新して画面が変わらない
私が実際にやってしまった典型的な失敗が、pushで配列に要素を追加しようとしたケースです。
❌ Before:pushで直接配列を書き換える
import { useState } from "react";
function BrokenTodoList() {
const [todos, setTodos] = useState<string[]>(["買い物"]);
const addTodo = (newTodo: string) => {
todos.push(newTodo); // 元の配列を直接書き換えている
setTodos(todos);
};
return (
<div>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
<button onClick={() => addTodo("掃除")}>追加</button>
</div>
);
}
export default BrokenTodoList;
このコードは一見動きそうに見えますが、ボタンを押しても画面のリストが更新されません。
todos.push(newTodo)は元の配列を直接変更しているだけで、setTodos(todos)に渡している配列は「参照としては変わっていない」ため、Reactが変更を検知できないのです。
私はこのバグに遭遇したとき、コンソールでconsole.log(todos)すると確かに要素は増えているのに、画面には一切反映されず、しばらく原因がわからずハマった経験があります。
✅ After:スプレッド構文で新しい配列を作る
import { useState } from "react";
function FixedTodoList() {
const [todos, setTodos] = useState<string[]>(["買い物"]);
const addTodo = (newTodo: string) => {
setTodos([...todos, newTodo]); // 新しい配列を作って置き換える
};
return (
<div>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
<button onClick={() => addTodo("掃除")}>追加</button>
</div>
);
}
export default FixedTodoList;
push・splice・sortのように元の配列自体を書き換える「破壊的メソッド」は、Reactのstate更新では避け、map・filter・スプレッド構文のように新しい配列を返す方法を使う、と覚えておきましょう。
応用・一歩先の使い方
更新前の値をもとに更新する場合は関数を渡す
これまでの例ではsetCount(count + 1)のように直接値を渡していましたが、連続して更新する場合や、非同期処理の後に更新する場合は「関数を渡す」書き方の方が安全です。
import { useState } from "react";
function SafeCounter() {
const [count, setCount] = useState(0);
const incrementTwice = () => {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
};
return (
<div>
<p>{count}</p>
<button onClick={incrementTwice}>2回まとめて+1</button>
</div>
);
}
export default SafeCounter;
setCount(count + 1)を2回連続で呼ぶと、どちらも同じcountを参照してしまい、結果的に1回分しか増えないことがあります。
setCount((prev) => prev + 1)のように「直前の最新値」を引数で受け取る形にすることで、この問題を避けられます。
まとめ
この記事のポイント
- state更新は常に「新しい値への置き換え」であり、直接の書き換えではない
- オブジェクトの更新はスプレッド構文で新しいオブジェクトを作って置き換える
- 配列の更新も
pushなどの破壊的メソッドは避け、スプレッド構文・map・filterを使う - 元のstateを直接書き換えると、Reactが変更を検知できず画面が更新されない
- 直前の値をもとに更新する場合は
setState((prev) => ...)の関数形式を使うと安全
次に読むべき記事
次回は、コンポーネントの外部とのやり取りを担うuseEffectの基本を解説します。
state更新の仕組みを理解したところで、副作用処理という新しいテーマに進みましょう。
→ 次の記事:useEffectの基本:副作用処理を理解する