useStateは、コンポーネントに「覚えておく値(state)」を持たせるためのReactのフックで、const [値, 更新関数] = useState(初期値)の形で使います。 更新関数を呼ぶとReactがコンポーネントをもう一度描画し、画面が新しい値に変わります。配列やオブジェクトは、中身を書き換えるのではなく「新しいものを作って渡す」のが決まりです。

この記事では、配列とオブジェクトの更新パターンをコピペできる早見表にまとめ、「更新したのに画面が変わらない」「値が古いまま」という3つのつまずきを実際に再現します。React 19.3.0 と TypeScript 7.0.2 を Node.js 22 上で動かし、コンソールの出力とtsc --noEmitのエラー文をそのまま載せています。

useStateの早見表

基本の形

やりたいこと コード
stateを作る const [count, setCount] = useState(0)
値を入れる setCount(5)
前の値をもとに更新する setCount((prev) => prev + 1)
重い初期値を1回だけ計算する useState(() => createInitial())
型を指定する(TypeScript) useState<string[]>([])

配列の更新

prevは更新前の配列です。どれも元の配列を書き換えず、新しい配列を返します。

やりたいこと コード
末尾に追加 setList((prev) => [...prev, item])
先頭に追加 setList((prev) => [item, ...prev])
途中(i番目)に挿入 setList((prev) => [...prev.slice(0, i), item, ...prev.slice(i)])
条件に合うものを削除 setList((prev) => prev.filter((t) => t.id !== id))
i番目を削除 setList((prev) => prev.filter((_, idx) => idx !== i))
条件に合う要素を置換 setList((prev) => prev.map((t) => (t.id === id ? { ...t, done: true } : t)))
i番目を置換 setList((prev) => prev.with(i, item))
並べ替え setList((prev) => prev.toSorted((a, b) => a.id - b.id))
逆順 setList((prev) => prev.toReversed())
空にする setList([])

オブジェクトの更新

やりたいこと コード
1つのプロパティを変更 setUser((prev) => ({ ...prev, name: "花子" }))
名前を変数で指定して変更 setUser((prev) => ({ ...prev, [key]: value }))
ネストしたプロパティを変更 setUser((prev) => ({ ...prev, address: { ...prev.address, city: "大阪" } }))
中の配列に追加 setUser((prev) => ({ ...prev, tags: [...prev.tags, "b"] }))
深い階層をまとめて複製して変更 structuredClone(prev)で複製してから書き換えて返す

更新されない原因

症状 原因 直し方
画面が変わらない pushなどで元の配列・オブジェクトを書き換え、同じものを渡した スプレッド構文やmap / filterで新しく作る
set直後のconsole.logが古い値 stateは次の描画で変わる。今の描画の変数は変わらない 新しい値を変数に入れて使う
3回setしたのに1しか増えない 3回とも同じ古い値から計算している 関数型更新(prev) => prev + 1にする

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useStateの使い方|配列・オブジェクト更新と更新されない原因」

useStateの基本の使い方

カウンターで動きを確認する

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>現在の値:{count}</p>
      <button onClick={() => setCount(count + 1)}>1増やす</button>
      <button onClick={() => setCount(0)}>リセット</button>
    </div>
  );
}
部分 意味
useState(0) 初期値0のstateを作る。初期値が使われるのは最初の描画だけ
count 今の描画での値
setCount 値を更新して、再描画を予約する関数

流れは次のとおりです。

flowchart LR
  A[ボタンを押す] --> B[setCount を呼ぶ]
  B --> C[React が再描画を予約]
  C --> D[コンポーネント関数をもう一度実行]
  D --> E[useState が新しい値を返す]
  E --> F[画面が更新される]

大事なのは、countは描画のたびに作り直される「その回の値」だということです。 setCountを呼んでも、今実行中の関数の中のcountは変わりません。次の描画で新しいcountが渡されます。このしくみが、あとで出てくる「更新されない」の理由になります。

フックを書く場所の決まり

useStateは、コンポーネント関数のいちばん上の階層で呼びます。ifやforの中、普通の関数の中では呼べません。Reactは「何番目に呼ばれたuseStateか」で値を見分けているので、呼ばれる順番が毎回同じである必要があるためです。

複数のstateを持つ

const [name, setName] = useState("");
const [age, setAge] = useState(0);
const [agreed, setAgreed] = useState(false);

一緒に変わる値は1つのオブジェクトにまとめ、別々に変わる値は分けると扱いやすくなります。フォームのように項目が多いものはオブジェクト、開閉フラグのような独立した値は単独のstateが向いています。

配列を更新する

検証に使ったコード

ここからの実行結果は、次のコンポーネントをjsdom(Node.js上でブラウザーのDOMを再現するライブラリ)30.1.1に描画し、ボタンを順番にクリックして得たものです。

import { useState } from "react";

type Todo = { id: number; title: string; done: boolean };

export function TodoPatterns() {
  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, title: "買い物", done: false },
    { id: 2, title: "掃除", done: true },
    { id: 3, title: "洗濯", done: false },
  ]);

  const add = () =>
    setTodos((prev) => [...prev, { id: 4, title: "料理", done: false }]);
  const unshift = () =>
    setTodos((prev) => [{ id: 0, title: "起床", done: true }, ...prev]);
  const insert = () =>
    setTodos((prev) => [...prev.slice(0, 1), { id: 9, title: "休憩", done: false }, ...prev.slice(1)]);
  const remove = () =>
    setTodos((prev) => prev.filter((t) => t.id !== 2));
  const replace = () =>
    setTodos((prev) => prev.map((t) => (t.id === 3 ? { ...t, done: true } : t)));
  const replaceAt = () =>
    setTodos((prev) => prev.with(0, { ...prev[0], title: "買い出し" }));
  const sort = () =>
    setTodos((prev) => prev.toSorted((x, y) => y.id - x.id));
  const reverse = () =>
    setTodos((prev) => prev.toReversed());

  return (
    <div>
      <button onClick={add}>追加</button>
      <button onClick={unshift}>先頭に追加</button>
      <button onClick={insert}>挿入</button>
      <button onClick={remove}>削除</button>
      <button onClick={replace}>置換</button>
      <button onClick={replaceAt}>番号で置換</button>
      <button onClick={sort}>並べ替え</button>
      <button onClick={reverse}>逆順</button>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>{t.title}{t.done ? "✓" : ""}</li>
        ))}
      </ul>
    </div>
  );
}

上から順にボタンを押したときの、画面の内容です(id:タイトルの形で表示、✓は完了)。検証では、同じ更新式を1つのコンポーネントにまとめ、表示を1行の文字列にして記録しました。

add      1:買い物 2:掃除✓ 3:洗濯 4:料理
unshift  0:起床✓ 1:買い物 2:掃除✓ 3:洗濯 4:料理
insert   0:起床✓ 9:休憩 1:買い物 2:掃除✓ 3:洗濯 4:料理
remove   0:起床✓ 9:休憩 1:買い物 3:洗濯 4:料理
replace  0:起床✓ 9:休憩 1:買い物 3:洗濯✓ 4:料理
with     0:買い出し✓ 9:休憩 1:買い物 3:洗濯✓ 4:料理
sort     9:休憩 4:料理 3:洗濯✓ 1:買い物 0:買い出し✓
reverse  0:買い出し✓ 1:買い物 3:洗濯✓ 4:料理 9:休憩

追加:スプレッド構文で新しい配列を作る

setTodos((prev) => [...prev, newTodo]);   // 末尾
setTodos((prev) => [newTodo, ...prev]);   // 先頭

[...prev, newTodo]は「prevの中身を並べ、最後にnewTodoを置いた新しい配列」です。prev自体は変わりません。

削除:filterで残すものを選ぶ

setTodos((prev) => prev.filter((t) => t.id !== id));

filterは条件に合う要素だけを集めた新しい配列を返します。「消したいもの以外を残す」と考えると書きやすくなります。

置換:mapで1つだけ差し替える

setTodos((prev) =>
  prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);

配列の中のオブジェクトを変えるときは、対象の要素も{ ...t, done: true }と新しいオブジェクトにするのがポイントです。t.done = trueと書き換えると、配列は新しくても中のオブジェクトは元のままなので、子コンポーネントが変化に気づけないことがあります。

並べ替え:toSortedを使う

sortとreverseは元の配列を書き換えるメソッドです。stateにはES2023で加わったtoSorted・toReversed・withを使うと、新しい配列が返るのでそのまま渡せます。対応していない環境に向けては、[...prev].sort(...)のように複製してから並べ替えます。

使ってよいメソッド・避けたいメソッド

目的 元の配列を書き換える(stateでは避ける) 新しい配列を返す(stateに向く)
追加 push / unshift [...arr, x] / concat
削除 pop / shift / splice filter / slice / toSpliced
置換 arr[i] = x / splice map / with
並べ替え sort / reverse toSorted / toReversed

オブジェクトとネストを更新する

検証に使ったコード

import { useState } from "react";

type User = {
  name: string;
  address: { city: string; zip: string };
  tags: string[];
};

export function UserPatterns() {
  const [user, setUser] = useState<User>({
    name: "太郎",
    address: { city: "東京", zip: "100-0001" },
    tags: ["a"],
  });

  const rename = () => setUser((prev) => ({ ...prev, name: "花子" }));
  const move = () =>
    setUser((prev) => ({ ...prev, address: { ...prev.address, city: "大阪" } }));
  const addTag = () =>
    setUser((prev) => ({ ...prev, tags: [...prev.tags, "b"] }));
  const byKey = () => {
    const key = "name" as const;
    setUser((prev) => ({ ...prev, [key]: "次郎" }));
  };
  const deep = () =>
    setUser((prev) => {
      const next = structuredClone(prev);
      next.address.zip = "530-0001";
      return next;
    });

  return (
    <div>
      <button onClick={rename}>名前</button>
      <button onClick={move}>市区</button>
      <button onClick={addTag}>タグ</button>
      <button onClick={byKey}>キー指定</button>
      <button onClick={deep}>複製して変更</button>
      <pre>{JSON.stringify(user)}</pre>
    </div>
  );
}

実行結果:

name     {"name":"花子","address":{"city":"東京","zip":"100-0001"},"tags":["a"]}
city     {"name":"花子","address":{"city":"大阪","zip":"100-0001"},"tags":["a"]}
tag      {"name":"花子","address":{"city":"大阪","zip":"100-0001"},"tags":["a","b"]}
key      {"name":"次郎","address":{"city":"大阪","zip":"100-0001"},"tags":["a","b"]}
clone    {"name":"次郎","address":{"city":"大阪","zip":"530-0001"},"tags":["a","b"]}

スプレッド構文は1階層だけ複製する

{ ...prev, name: "花子" }は、prevのプロパティを並べたうえでnameだけ上書きした新しいオブジェクトです。矢印関数でオブジェクトを返すときは、全体を丸かっこ( )で囲みます。囲まないと、波かっこが関数の本体として解釈されます。

スプレッド構文が複製するのは1階層だけなので、ネストした部分を変えるときは、通り道にある階層をそれぞれスプレッドします。

setUser((prev) => ({
  ...prev,                      // 1階層目を複製
  address: {
    ...prev.address,            // 2階層目を複製
    city: "大阪",               // 変えたいところだけ上書き
  },
}));

階層が深いときの選択肢

方法 向いている場面 注意点
スプレッドを重ねる 2〜3階層まで 階層が増えると読みにくくなる
structuredCloneで複製してから書き換える 深い階層をまとめて変える 全体を複製するので、大きなデータでは負荷がかかる。関数は複製できない
stateを平らに設計し直す idで参照し合うデータ 最初の設計が必要
Immer(use-immer) 深い更新が多い ライブラリの追加が必要(今回は未実行)

フォームをオブジェクトで持つ

入力欄のname属性をキーにすると、1つの関数で全項目を更新できます。

import { useState } from "react";
import type { ChangeEvent } from "react";

type Form = { name: string; email: string };

export function ProfileForm() {
  const [form, setForm] = useState<Form>({ name: "", email: "" });

  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

  return (
    <form>
      <input name="name" value={form.name} onChange={onChange} />
      <input name="email" value={form.email} onChange={onChange} />
    </form>
  );
}

(このフォームは型チェックのみ確認し、入力の動作は未実行です。)

「更新されない」3つの原因を再現する

原因1:pushで直接書き換えている

export function PushDirect() {
  const [items, setItems] = useState<string[]>(["A"]);
  console.log("[PushDirect] render:", JSON.stringify(items));

  return (
    <div>
      <button
        onClick={() => {
          items.push("B");
          setItems(items);
          console.log("[PushDirect] push後の items:", JSON.stringify(items));
        }}
      >
        push
      </button>
      <button onClick={() => setItems([...items, "C"])}>spread</button>
      <p>{items.join(",")}</p>
    </div>
  );
}

pushのボタン、spreadのボタンの順に押した結果です。

[PushDirect] render: ["A"]
--- click push
[PushDirect] push後の items: ["A","B"]
画面: A
--- click spread
[PushDirect] render: ["A","B","C"]
画面: A,B,C

pushのあと、配列の中身は["A","B"]になっているのに、renderのログが出ず、画面はAのままです。ReactはsetItemsに渡された値と今のstateをObject.isで比べ、同じなら再描画を省きます。pushは同じ配列に要素を足すだけなので、Reactから見ると「前と同じ配列が渡された」ことになります。

spreadのボタンを押すと新しい配列が渡されるので再描画され、隠れていたBも一緒に表示されました。「別の操作をしたら急に反映された」という動きは、この直接書き換えが原因であることが多いです。

オブジェクトでも同じです。obj.v = 2; setObj(obj);を実行しても画面は1のままで、setObj({ ...obj, v: 3 })にすると3に変わりました。

--- click same-obj
画面: 1
--- click new-obj
画面: 3

直し方:setItems((prev) => [...prev, "B"])のように、新しい配列・オブジェクトを作って渡します。

原因2:set直後にconsole.logで見ている

export function LogAfterSet() {
  const [count, setCount] = useState(0);
  console.log("[LogAfterSet] render: count =", count);

  return (
    <button
      onClick={() => {
        setCount(count + 1);
        console.log("[LogAfterSet] set直後: count =", count);
        const next = count + 1;
        console.log("[LogAfterSet] 変数に入れた next =", next);
      }}
    >
      {count}
    </button>
  );
}
[LogAfterSet] render: count = 0
--- click
[LogAfterSet] set直後: count = 0
[LogAfterSet] 変数に入れた next = 1
[LogAfterSet] render: count = 1

setCount(count + 1)の直後でもcountは0のままです。そのあと再描画が走り、renderのログで1になっています。これは更新が止まっているのではなく、正しい動きです。 countはその描画の回の値で、setは「次の描画ではこの値にして」という予約だからです。

直し方:更新後の値をすぐ使いたいときは、先に変数へ入れてからsetとほかの処理の両方に使います。

const next = count + 1;
setCount(next);
saveToServer(next);   // 新しい値を使う処理

「stateが変わったあとに何かしたい」場合は、イベントの処理の中でnextを使うのが第一候補です。描画結果に連動する処理だけuseEffectに任せます。

原因3:連続でsetしても1しか増えない

export function Triple() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  console.log("[Triple] render: a =", a, "b =", b);

  return (
    <div>
      <button onClick={() => { setA(a + 1); setA(a + 1); setA(a + 1); }}>a</button>
      <button onClick={() => { setB((n) => n + 1); setB((n) => n + 1); setB((n) => n + 1); }}>b</button>
      <p>{a}/{b}</p>
    </div>
  );
}
[Triple] render: a = 0 b = 0
--- click a
[Triple] render: a = 1 b = 0
画面: 1/0
--- click b
[Triple] render: a = 1 b = 3
画面: 1/3

setA(a + 1)を3回呼んでも、aは1にしかなりません。クリックした時点のaは0なので、3回ともsetA(0 + 1)、つまり「1にして」を3回予約しただけだからです。

setB((n) => n + 1)のように関数を渡す形(関数型更新)にすると、Reactが「直前の予約の結果」をnに入れて順番に実行するので、0→1→2→3と進みます。再描画はどちらも1回だけです。

書き方 渡しているもの 3回呼んだ結果
setA(a + 1) 計算済みの値(1) 1
setB((n) => n + 1) 計算のしかた 3

時間差のある処理でも同じことが起きる

setTimeoutや通信のあとでsetするときも、クリックした時点の古い値を使ってしまうことがあります。50ミリ秒後にsetN(n + 1)を実行するボタンを、すばやく2回押した結果です(押す前の画面は2)。

[Timer] render n = 3
stale を2回連打後の画面: 3

2回押したのに1しか増えていません。2回ともnが2の時点で作られた処理なので、どちらもsetN(3)になったためです。ここもsetN((p) => p + 1)にすれば、押した回数だけ増えます。

関数型更新を使う目安

場面 書き方
前の値をもとに計算する(増減、追加、切り替え) 関数型更新 set((prev) => ...)
前の値と無関係な値を入れる(入力値、取得したデータ、リセット) 値をそのまま渡す set(value)
setTimeout、Promise、イベント購読の中 関数型更新

「前の値を使うなら関数型更新」と決めておくと、3つ目の原因と時間差の問題をまとめて避けられます。

知っておくと役立つ挙動

同じ値をセットしても再描画されない

useState(0)の状態でsetN(0)を呼んでも、renderのログは出ませんでした。値が同じなら再描画を省く、という最適化です。原因1はこの最適化が配列やオブジェクトにも働いた結果です。

初期値の計算を1回にする(遅延初期化)

function createInitial(): number[] {
  console.log("[Lazy] 初期化関数の呼び出し");
  return [1, 2, 3];
}

const [list] = useState(createInitial);        // 関数そのものを渡す
// const [list] = useState(createInitial());   // 描画のたびに計算が走る

関数そのものを渡した場合、ボタンを2回押して再描画させても、呼び出しのログは最初の1回だけでした。useState(createInitial())と書くと、結果は使われないのに描画のたびに計算が走ります。localStorageの読み込みや大きな配列の生成は、関数で渡すと無駄がなくなります。

関数をstateに入れたいとき

useStateとsetは、渡された関数を「初期化関数」「更新関数」として実行します。関数そのものをstateに持ちたいときは、useState(() => fn)、setFn(() => fn)と、関数を返す関数の形にします(react.devのuseStateのページで確認。今回は未実行)。

stateをリセットする

コンポーネントに渡すkeyを変えると、Reactはそのコンポーネントを作り直し、中のstateがすべて初期値に戻ります。入力フォームを対象ごとに切り替えるときに便利です(react.devのuseStateのページで確認。今回は未実行)。

<ProfileForm key={userId} />

TypeScriptでの型指定

型が推論される場合と、指定が必要な場合

初期値 推論される型 型引数
useState(0) number 不要
useState("") string 不要
useState(false) boolean 不要
useState({ name: "", age: 0 }) そのオブジェクトの形 不要(型に名前を付けたいときは指定)
useState([]) never[] 必要。useState<string[]>([])
useState(null) null 必要。Userまたはnullの共用体型を指定
useState("idle") string 決まった文字列だけにしたいなら指定
useState<number>() numberまたはundefined 初期値なしのとき

エラーになる例と実際のエラー文

型引数を省いた書き方を集めて、tsc --noEmit(TypeScript 7.0.2、strict: true)にかけました。

import { useState } from "react";

type User = { id: number; name: string };

export function Ng() {
  const [list, setList] = useState([]);
  const [user, setUser] = useState(null);
  const [selected, setSelected] = useState<User | null>(null);
  const [count, setCount] = useState(0);
  const [status, setStatus] = useState("idle");
  const [form, setForm] = useState({ name: "", age: 0 });

  setList([...list, "A"]);                      // 13行目
  setUser({ id: 1, name: "太郎" });             // 14行目
  console.log(selected.name);                   // 15行目
  setCount("1");                                // 16行目
  setForm({ name: "花子" });                    // 17行目
  setForm((prev) => ({ ...prev, agee: 20 }));   // 18行目
  const s: "idle" | "loading" = status;         // 19行目
  return null;
}
ng.tsx(13,12): error TS2322: Type 'string' is not assignable to type 'never'.
ng.tsx(13,21): error TS2322: Type 'string' is not assignable to type 'never'.
ng.tsx(14,13): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type '(prevState: null) => null'.
ng.tsx(15,15): error TS18047: 'selected' is possibly 'null'.
ng.tsx(16,12): error TS2345: Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>'.
ng.tsx(17,11): error TS2345: Argument of type '{ name: string; }' is not assignable to parameter of type 'SetStateAction<{ name: string; age: number; }>'.
  Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: number; }'.
ng.tsx(19,9): error TS2322: Type 'string' is not assignable to type '"idle" | "loading"'.
行 エラーの意味 直し方
13 useState([])は「何も入らない配列」と推論される useState<string[]>([])
14 useState(null)は「nullだけ」と推論される 型引数にUserとnullの共用体型を指定する
15 nullかもしれない値のプロパティを読んでいる selected?.name、またはif (selected)で絞り込む
16 数値のstateに文字列を渡している setCount(Number("1"))
17 オブジェクトの一部だけを渡している。setは合成せず、丸ごと置き換える setForm((prev) => ({ ...prev, name: "花子" }))
19 stringと推論されたstateを、決まった文字列の型に入れている 文字列リテラルの共用体型を型引数に指定する

18行目のagee(ageの打ち間違い)は、エラーになりませんでした。 スプレッドで作ったオブジェクトを関数型更新から返す形では、余分なプロパティのチェックが働かないためです。プロパティ名の打ち間違いは型チェックをすり抜けることがあるので、エディターの入力補完を使って書くと安心です。

エラーなしで通る書き方

import { useState } from "react";
import type { Dispatch, SetStateAction } from "react";

type User = { id: number; name: string };
type Status = "idle" | "loading" | "done";
type Form = { name: string; age: number };

export function Ok() {
  const [list, setList] = useState<string[]>([]);
  const [user, setUser] = useState<User | null>(null);
  const [count, setCount] = useState(0);            // number と推論される
  const [status, setStatus] = useState<Status>("idle");
  const [form, setForm] = useState<Form>({ name: "", age: 0 });
  const [late, setLate] = useState<number>();       // number | undefined

  setList((prev) => [...prev, "A"]);
  setUser({ id: 1, name: "太郎" });
  console.log(user?.name ?? "未選択");
  setCount((c) => c + 1);
  setStatus("loading");
  setForm((prev) => ({ ...prev, name: "花子" }));
  setLate(1);

  return <Child value={count} onChange={setCount} />;
}

function Child(props: { value: number; onChange: Dispatch<SetStateAction<number>> }) {
  return <button onClick={() => props.onChange((v) => v + 1)}>{props.value}</button>;
}

このファイルはtsc --noEmitでエラーが出ないことを確認しています(型チェックのための例なので、描画の途中でsetを呼ぶ形になっています。実際のコードではイベントの処理の中で呼びます)。

更新関数を子コンポーネントに渡すときの型はDispatch<SetStateAction<number>>です。 子が関数型更新を使わないなら、(value: number) => voidと書いても受け取れます。

React 19での挙動

React 19.3.0で確認した内容と、react.devのuseStateのページ(今回開いて確認)の記載を整理します。

項目 React 19での挙動 確認方法
基本のAPI const [state, setState] = useState(initialState)のまま 今回実行
まとめて反映(バッチ処理) イベントの処理が終わってから1回だけ再描画。setTimeoutの中でもまとめられる 今回実行
同じ値のset Object.isで同じなら再描画を省く 今回実行
Strict Mode 開発時、初期化関数と更新関数を2回ずつ呼ぶ 今回実行
本番ビルドでのStrict Mode 2回呼ばれるのは開発時だけ react.devで確認(本番ビルドでは未実行)

setTimeoutの中でもまとめて反映される

setTimeoutの中で関数型更新を2回呼んだ結果です。

--- click timer
[Timer] render n = 2
画面: 2

2回のsetに対して、renderのログは1回だけでした。React 18以降はイベントの外(タイマーやPromiseの中)でも自動でまとめて反映されます。

Strict Modeで2回呼ばれるのは仕様

StrictModeで囲んだコンポーネントでは、初期化関数と更新関数が2回ずつ呼ばれました(初期化関数の「1回目」は、Strict Modeなしで描画したときの呼び出しです)。

[Lazy] 初期化関数の呼び出し 2 回目
[Lazy] 初期化関数の呼び出し 3 回目
--- click strict
[Strict] updater 実行 p = 0
[Strict] updater 実行 p = 0
画面: 1

更新関数は2回ともp = 0で呼ばれ、画面は1です。値が2増えるわけではありません。これは、関数の中で外の値を書き換えていないか(純粋かどうか)を確かめるための開発時の動きです。更新関数の中でprev.push(x)のように書き換えていると、2回呼ばれたときに要素が2つ増えて気づける、というしくみになっています。

// 2回呼ばれると2つ増えてしまう書き方
setTodos((prev) => { prev.push(item); return prev; });

// 何回呼ばれても結果が同じ書き方
setTodos((prev) => [...prev, item]);

useStateとほかのフックの使い分け

状況 向いているもの
画面に出す単純な値 useState
更新の種類が多く、ロジックをまとめたい useReducer
画面に出さない値(タイマーIDなど) useRef
ほかのstateやpropsから計算できる値 stateにせず、描画中に計算する
フォーム送信の結果や待ち状態 React 19のuseActionState(今回は未実行)

「合計」や「絞り込んだ一覧」のように、ほかの値から計算できるものはstateにしないほうが、更新のし忘れが起きません。

const [todos, setTodos] = useState<Todo[]>([]);
const doneCount = todos.filter((t) => t.done).length;   // stateにせず計算する

よくある質問

Q. setしたのに画面が更新されません。まず何を確認すればよいですか? A. 配列やオブジェクトをpushや代入で直接書き換えていないかを確認します。Reactは前と同じ配列・オブジェクトが渡されると再描画を省きます。スプレッド構文やmap・filterで新しく作って渡すと反映されます。

Q. set直後に新しい値を使いたいときはどうすればよいですか? A. const next = count + 1;のように先に変数へ入れ、setCount(next)とそのあとの処理の両方でnextを使います。stateの変数は、次の描画になるまで変わりません。

Q. 関数型更新はいつも使うべきですか? A. 前の値をもとに計算するとき(増減、配列への追加、切り替え)は関数型更新が確実です。入力欄の値のように、前の値と関係のない値を入れるときは、値をそのまま渡して問題ありません。

Q. オブジェクトの一部だけをsetに渡すと、残りはどうなりますか? A. 残りは消えます。useStateのsetは値を丸ごと置き換えるので、{ ...prev, name: "花子" }のように、残したい部分をスプレッドで並べます。TypeScriptなら、プロパティの不足を型エラーとして知らせてくれます。

まとめ

  • useStateは[値, 更新関数]を返す。 更新関数を呼ぶと再描画され、次の描画で新しい値になる
  • 配列・オブジェクトは書き換えず、新しく作って渡す。 pushやsortの代わりに、スプレッド構文・filter・map・toSortedを使う
  • 画面が変わらないときは、同じ配列・オブジェクトを渡していないかを見る
  • set直後のconsole.logが古い値なのは正しい動き。 すぐ使う値は変数に入れておく
  • 前の値をもとに更新するなら関数型更新(prev) => ...。 連続のsetや時間差のある処理でも正しく積み上がる
  • TypeScriptでは、空配列とnullの初期値に型引数を付ける
  • Strict Modeで初期化関数・更新関数が2回呼ばれるのは開発時の確認用。 結果が変わらない書き方にしておけば影響はない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useStateの使い方|配列・オブジェクト更新と更新されない原因」

株式会社RJCでは、ReactのuseStateをはじめとした状態の扱い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?