14
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

かんたん自販機で考える React のロジックいろは #1 状態・ステート・写像

Last updated at Posted at 2023-03-25

お題

一種類の缶入りジュース(代金3ゴールド)が売ってある自販機を考えてみましょう。

  • 投入できるコインは、1ゴールド硬貨の一種類のみ、
  • 投入したコインが3枚以上のときに「購入」ボタンを押すと缶が出てきます。
    • 代金3ゴールドを差し引いて、残りのコインはお釣りとして出てきます。
  • 投入したコインが3枚になる前に「返却」ボタンを押すと、投入したコインが出てきます。

この仕様を、

  • コンポーネントの持つ状態
  • ユーザーイベントによって実行される処理

に着目して React のコンポーネントに落とし込んでみましょう。

"use client";

import { FC, useState } from "react";

export const VendingApp: FC = () => {
  // 1. ステート

  // 一時的にコインを保持するストレージ
  // (購入したあとに飲み込まれたコインは省略)
  const [coins, setCoins] = useState<number>(0);

  // 3. ユーザーイベント

  // コインを挿入
  const insertCoin = () => {
    setCoins(coins + 1);
  };

  // 購入
  const buy = () => {
    if (coins < 3) {
      return;
    }
    const prevCoins = coins
    setCoins(0);
    window.alert("缶が出てきました!");
    window.alert(`${prevCoins - 3}枚のお釣りが出てきました!`);
    // 遠回りなコードに見えますが、 
    // stale closure のことを考えなくて良いようにわざとです。
  };

  // お釣りを取り出す
  const receiveChange = () => {
    const prevCoins = coins;
    setCoins(0);
    window.alert(`${prevCoins}枚のお釣りが返ってきました!`);
  };

  // 4. JSX で UI を組み立てる
  // 関数は第一級オブジェクトなので、 onClick={insertCoin} のように書いても問題ありません。
  return (
    <div>
      <div>コイン: {coins}</div>
      <div>
        <button onClick={() => insertCoin()}>コインを入れる</button>
        <button onClick={() => buy()}>購入する</button>
        <button onClick={() => receiveChange()}>コインを返却</button>
      </div>
    </div>
  );
};

購入ボタンを非活性化する条件を追加

ちょっとした仕様を追加してみましょう。

コインが3枚未満のときに「購入する」ボタンが押せるのはおかしいので、そのような条件では「購入できない」として、ボタンを非活性化(disable)しましょう。

// 1. ステート
const [coins, setCoins] = useState<number>(0);

// 購入可能かどうかの状態
const [canBuy, setCanBuy] = useState<boolean>(false);

useEffect(() => {
  setCanBuy(coins >= 3);
}, [coins]);

// 中略
<button disabled={!canBuy} onClick={() => buy()}>
  購入する
</button>

...

ちょっと待って下さい、このコードは

  • 初期値は false、それ以降は「coins >= 3」かどうかで条件判定をしている
    • 暗黙的に、同じ知識を二箇所に書いている (DRY 原則違反)
  • 「setCanBuy が他の要因で更新されることがない」ことを保証できない

という問題があります。

頭を一度クリアして、もっとシンプルに、こう考えてみてはいかがでしょうか?

canBuy とは、つねに coins >= 3 であるときに真であり、そうでないときは偽である

実をいうと、React はそのような仕様を素直にコードに落とし込むことができるパラダイムになっていて、さきほど挙げた useEffect を使ったコードの問題点をクリアしています。

  // 1. ステート

  const [coins, setCoins] = useState<number>(0);

+ // 2. 写像
+
+ // 購入可能かどうか
+ const canBuy = coins >= 3;

- // 購入可能かどうかの状態
- const [canBuy, setCanBuy] = useState<boolean>(false);
-
- useEffect(() => {
-   setCanBuy(coins >= 3);
- }, [coins]);

もちろん、以下のようなケースであれば、2つのステートと useEffect で実装するべきですが、

  • 独自に初期状態を持つ
  • ほかの要因によって状態が変わることがある
  • 非同期である (use() が使えるようになれば useEffect が不要になります)

今回の場合に限ってはステートを追加しないコードのほうが、的確に仕様を噛み砕いて React のコードに落とし込めた解釈である、と言えるでしょう。

まるで、スクリーン上の影絵が、手の動きに合わせて形をかえるのと同じような関係なので、仮に「写像」と呼ぶことにします。

この「つねに」は、数学や物理で言うところの

  • $y$ は $x$ の関数であり $y = 2x + 3$ と表せる。
  • 数学における恒等式
  • 物理における方程式 (例: $m\vec{a}=\vec{F}$)

に似ていると思います。

補足「なぜこんな書き方ができるの?」と思った人へ

const SomeComponent: FC = () => {
  // 関数の中身
}

なぜコンポーネントの中に書いた式が「つねに」という意味になるのかというと、 React が再レンダリングのたびに「関数の中身」を全て、上から下まで順番に実行 しているからです。

手前味噌ですが、この React 関数コンポーネントのアーキテクチャについて詳しめに解説したので、気になったら読んでみてください。

まとめ

このようにして、素朴に「状態」だと思われていたものも、

  • ステート
    • 独立した状態
    • 初期値が決まっている
    • set〇〇 したとき以外は値が再計算されず、再利用される
  • 写像
    • 他のステートや写像に(即座に)連動する
    • 独自に初期値を持ったり、独自に値が変化することがない

に分けることが出来ます。少し話は飛躍しますが、

  • 状態
    • 真の状態(ステート)
    • 従属的な状態(写像)
  • ユーザーイベント
  • 外部システムとの同期(useEffect)

これらをキッチリと意識しながら仕様を噛み砕くことで、コードを簡潔に保つことができ、(誤差程度でしょうが)画面のチラツキや無駄な再レンダリングを防げます。

何よりも嬉しいのは「仕様が変更されたけど、どこから直せばいいか分からない💦」「この式を修正したらどこかで予想外の挙動をするかもしれない...」という事態を避けることにも繋がる点です。

参考記事

React 公式のドキュメント。 useEffect を使わなくて良いケースについてもっと知りたければ、こちらも読んでみてください。

というか React は基礎さえ押さえれば簡単なので、公式ドキュメントを読みこむべき。

コード全文

"use client";

import { FC, useState } from "react";

export const VendingApp: FC = () => {
  // 1. 状態

  // 一時的にコインを保持するストレージ
  // (購入したあとに飲み込まれたコインは省略)
  const [coins, setCoins] = useState<number>(0);

  // 2. 写像

  // 購入できるかどうか
  const canBuy = coins >= 3;

  // 3. ユーザーイベント

  // コインを挿入
  const insertCoin = () => {
    setCoins(coins + 1);
  };

  // 購入
  const buy = () => {
    if (coins < 3) {
      return;
    }
    const prevCoins = coins;
    setCoins(0);
    window.alert("缶が出てきました!");
    window.alert(`${prevCoins - 3}枚のお釣りが出てきました!`);
  };

  // お釣りを取り出す
  const receiveChange = () => {
    const prevCoins = coins;
    setCoins(0);
    window.alert(`${prevCoins}枚のコインが出てきました!`);
  };

  // 4. JSX で UI を組み立てる

  return (
    <div>
      <div>コイン: {coins}</div>
      <div>
        <button onClick={() => insertCoin()}>コインを入れる</button>
        <button disabled={!canBuy} onClick={() => buy()}>
          購入する
        </button>
        <button onClick={() => receiveChange()}>コインを返却</button>
      </div>
    </div>
  );
};
14
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?