state のリフトアップ

Part 4 · 状態 (state)

state のリフトアップ

2 つの部品で同じ値を共有したくなったら

state はコンポーネントごとに持てます。持てるということは、バラバラになるということでもあります。

2 つの部品で同じ値を見せたいのに、それぞれが自分の state を持っていたら、いつまでも一致しません。

このときの定石がリフトアップです。 state を、共通の親へ引き上げます

それぞれが持つと、そろわない

同じ部品を 2 つ置いています。 どちらのボタンを押しても、押したほうしか増えません

子がそれぞれ state を持つうまくいかない例

上下で別々の数になる

上のパネル0
下のパネル0

これは不具合ではなく、当然の結果です。useStateそのコンポーネントごとに別の値を用意します。 同じコードから作られていても、置かれた 2 つは別物です。

「別々でいい」ならこれで正解です。 問題になるのは、そろっていてほしい場合です。

separate-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

// それぞれが自分の state を持っている
function Panel({ label }: { label: string }) {
  const [count, setCount] = useState(0);

  return (
    <div className="flex items-center justify-between gap-3 rounded-md border p-3">
      <span className="text-muted-foreground">{label}</span>
      <span className="font-mono font-semibold tabular-nums">{count}</span>
      <Button size="sm" onClick={() => setCount(count + 1)}>
        増やす
      </Button>
    </div>
  );
}

export function SeparateState() {
  return (
    <div className="flex flex-col gap-2.5">
      <Panel label="上のパネル" />
      <Panel label="下のパネル" />
    </div>
  );
}

共通の親へ引き上げる

解決は単純です。両方から見える場所、つまり共通の親に state を移します。

子には、Part 2 でやった形で渡します。

  • 表示するための
  • 変更を伝えるための関数
親が state を持つ直した例

どちらを押しても、両方が変わる

上のパネル0
下のパネル0

子のコードから useState が消えました。 子は値を受け取って表示し、押されたら伝えるだけです。 Part 2-3 でやった形と、まったく同じになっています。

lifted-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

// 子は値を受け取って表示するだけ。自分では持たない
function Panel({
  label,
  count,
  onIncrease,
}: {
  label: string;
  count: number;
  onIncrease: () => void;
}) {
  return (
    <div className="flex items-center justify-between gap-3 rounded-md border p-3">
      <span className="text-muted-foreground">{label}</span>
      <span className="font-mono font-semibold tabular-nums">{count}</span>
      <Button size="sm" onClick={onIncrease}>
        増やす
      </Button>
    </div>
  );
}

// 共通の親が値を持つ
export function LiftedState() {
  const [count, setCount] = useState(0);
  const increase = () => setCount((current) => current + 1);

  return (
    <div className="flex flex-col gap-2.5">
      <Panel label="上のパネル" count={count} onIncrease={increase} />
      <Panel label="下のパネル" count={count} onIncrease={increase} />
    </div>
  );
}

どこまで上げるか

「上げれば解決する」なら、最初から一番上に置けばよさそうに思えます。 ですが、それはやりません。

上げるほど、関係のない部品まで巻き込みます

  • 渡すために、途中の部品が使いもしない props を受け渡すことになる
  • 親の state が変わると、その下が広く描き直される(Part 7 以降の話)
  • どこで何が変わるのか、追いかける範囲が広くなる

バケツリレーがつらくなったら

必要な高さが本当に高く、 間の部品が使いもしない props をただ受け渡すだけになることがあります。これをバケツリレーと呼びます。

// Middle は user を使わないのに、渡すためだけに受け取っている
<Page user={user}>
  <Middle user={user}>
    <Profile user={user} />
  </Middle>
</Page>

この状態が深くなってきたら、Part 9 の Context という道具が候補になります。 ただしまずはリフトアップで足ります。 2 段や 3 段で困ることはほとんどありません。

lifted-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

// 子は値を受け取って表示するだけ。自分では持たない
function Panel({
  label,
  count,
  onIncrease,
}: {
  label: string;
  count: number;
  onIncrease: () => void;
}) {
  return (
    <div className="flex items-center justify-between gap-3 rounded-md border p-3">
      <span className="text-muted-foreground">{label}</span>
      <span className="font-mono font-semibold tabular-nums">{count}</span>
      <Button size="sm" onClick={onIncrease}>
        増やす
      </Button>
    </div>
  );
}

// 共通の親が値を持つ
export function LiftedState() {
  const [count, setCount] = useState(0);
  const increase = () => setCount((current) => current + 1);

  return (
    <div className="flex flex-col gap-2.5">
      <Panel label="上のパネル" count={count} onIncrease={increase} />
      <Panel label="下のパネル" count={count} onIncrease={increase} />
    </div>
  );
}

理解できたか確かめる

確認クイズ

同じコンポーネントを 2 つ置くと、state はどうなる?

確認クイズ

state の置き場所として適切なのは?

lifted-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

// 子は値を受け取って表示するだけ。自分では持たない
function Panel({
  label,
  count,
  onIncrease,
}: {
  label: string;
  count: number;
  onIncrease: () => void;
}) {
  return (
    <div className="flex items-center justify-between gap-3 rounded-md border p-3">
      <span className="text-muted-foreground">{label}</span>
      <span className="font-mono font-semibold tabular-nums">{count}</span>
      <Button size="sm" onClick={onIncrease}>
        増やす
      </Button>
    </div>
  );
}

// 共通の親が値を持つ
export function LiftedState() {
  const [count, setCount] = useState(0);
  const increase = () => setCount((current) => current + 1);

  return (
    <div className="flex flex-col gap-2.5">
      <Panel label="上のパネル" count={count} onIncrease={increase} />
      <Panel label="下のパネル" count={count} onIncrease={increase} />
    </div>
  );
}

この章のまとめ

  • useState はコンポーネントごとに別の値を持つ
  • そろえたいときは、共通の親へ state を上げる
  • 子には、値と変更を伝える関数を props で渡す
  • 置き場所は必要な部品すべてを含む、いちばん下
  • バケツリレーが深くなったら Context(Part 9)。 ただし多くの場合、リフトアップと合成で足りる

lifted-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

// 子は値を受け取って表示するだけ。自分では持たない
function Panel({
  label,
  count,
  onIncrease,
}: {
  label: string;
  count: number;
  onIncrease: () => void;
}) {
  return (
    <div className="flex items-center justify-between gap-3 rounded-md border p-3">
      <span className="text-muted-foreground">{label}</span>
      <span className="font-mono font-semibold tabular-nums">{count}</span>
      <Button size="sm" onClick={onIncrease}>
        増やす
      </Button>
    </div>
  );
}

// 共通の親が値を持つ
export function LiftedState() {
  const [count, setCount] = useState(0);
  const increase = () => setCount((current) => current + 1);

  return (
    <div className="flex flex-col gap-2.5">
      <Panel label="上のパネル" count={count} onIncrease={increase} />
      <Panel label="下のパネル" count={count} onIncrease={increase} />
    </div>
  );
}