Reactでstateにする値・しない値を分ける|計算できる値はstateに持たない

Reactで機能を追加していると、useStateで管理する値がどんどん増えていく。
最初のうちは、
「画面で使う値なら、とりあえずstateにしておけばいいのでは?」
と思っていた。
ただ、麻雀のオーラス条件を計算するアプリを作っていると、

  • 4人の持ち点
  • 自分の席
  • 目標順位
  • 本場
  • 供託
  • 現在順位
  • 終局後の点数
  • 終局後の順位
  • 必要なロン点
  • 必要なツモ点

と、扱う値がかなり増えてきた。
ここで全部をstateとして持つのではなく、「保存しておく必要がある値」と「ほかの値から計算できる値」を分けることにした。

今回は、その考え方を整理しておく。

stateとして持つ値

たとえば、4人の持ち点はユーザーが入力する。

const [scores, setScores] =
  useState<Scores>({
    east: 25000,
    south: 25000,
    west: 25000,
    north: 25000,
  });

この値はユーザーの操作によって変わり、その値を次の描画でも覚えておく必要がある。
そのため、stateとして持つ。
同じように、今回のアプリでは次のような値をstateで管理している。

  • 持ち点
  • 自分の席
  • 目標順位
  • 本場
  • 供託
  • 難易度
  • 終局方法
  • 和了者
  • 放銃者
  • ロン点
  • ツモ時の支払い
  • テンパイ者

どれもユーザーが変更できる入力値になっている。

現在順位はstateにしない

一方、現在順位はどうするか。
持ち点が、

東家 32000
南家 24500
西家 23000
北家 20500

なら、順位は点数から計算できる。

const ranking =
  calculateRanking(scores);

つまり、

scores(値)
↓
calculateRanking(関数)
↓
ranking(値)

という関係になっている。
scoresがあればrankingをいつでも求められるため、順位を別のstateとして保存しておく必要はない。

両方stateにすると同期が必要になる

仮に、点数と順位を両方stateにしたとする。

const [scores, setScores] =
  useState<Scores>(initialScores);

const [ranking, setRanking] =
  useState<RankingRow[]>(initialRanking);

点数を変更するときは、順位も同時に更新しなければならない。

setScores(newScores);
setRanking(
  calculateRanking(newScores),
);

毎回これを忘れずに行えば動く。
ただ、どこか一か所でsetRankingを忘れると、

画面に表示されている点数
↓
32000 / 24500 / 23000 / 20500

画面に表示されている順位
↓
変更前の順位

のように、点数と順位が食い違う可能性がある。
そもそも順位は点数から決まるので、別々に管理しない方が単純になる。

終局後の点数も計算できる

同じ考え方で、終局後の点数もstateにはしていない。
たとえばロンなら、

const finalScores = applyRon(
  scores,
  winner,
  loser,
  ronPoints,
  honba,
  kyotaku,
);

必要になる元の情報はすでにある。

現在の点数
和了者
放銃者
ロン点
本場
供託

これらから終局後点数を計算できる。
ツモならapplyTsumo、流局ならapplyDrawを使う。

if (resultType === "ron") {
  return applyRon(...);
}

if (resultType === "tsumo") {
  return applyTsumo(...);
}

return applyDraw(...);

終局方法などの入力値だけstateとして持ち、結果は必要なときに計算する形にした。

必要なロン点やツモ点も同じ

オーラス条件として表示している、

  • 必要なロン点
  • 必要なツモ点
  • 最低和了点

も入力値から求められる。
たとえば必要なロン点なら、

const requiredRonList =
  findRequiredRonList(
    scores,
    selfSeat,
    goal,
    honba,
    kyotaku,
  );

元になるのは、

現在の点数
自分の席
目標順位
本場
供託

だけ。
これらが変われば、必要点数も変わる。
そのため、必要点数そのものをstateに保存するのではなく、元になる値から再計算する。

stateにするか迷ったときの考え方

自分の中では、次のように考えると分かりやすかった。
その値が、
『ユーザーの操作などによって独立して変わるものか?』
それとも、
『ほかの値さえあれば計算できるものか?』
を見る。
今回のアプリなら、

持ち点
→ ユーザーが入力する
→ state

自分の席
→ ユーザーが選ぶ
→ state

目標順位
→ ユーザーが選ぶ
→ state

現在順位
→ 持ち点から計算できる
→ stateにしない

必要ロン点
→ 持ち点や目標から計算できる
→ stateにしない

終局後順位
→ 終局後点数から計算できる
→ stateにしない

という分け方になる。

useMemoは別の話

今回のコードでは、計算結果の一部にuseMemoを使っている。

const ranking = useMemo(
  () => calculateRanking(finalScores),
  [finalScores],
);

最初は、
「計算して求める値はuseMemoにする」
という理解をしていた。
ただ、これは少し違う。
単純に次のように書いても、順位をstateとして持たないという考え方自体は同じ。

const ranking =
  calculateRanking(finalScores);

useMemoは計算結果を一定の条件で再利用するための仕組みで、基本的には不要な再計算を避けるために使うもの。
つまり、
『stateとして持つか』
と、
『useMemoを使うか』
は別々に考えた方がよさそう。
まず「この値はstateとして保存する必要があるか」を考える。
そのうえで、計算量などを見て必要ならuseMemoを使う、という順番になる。

元になる値を少なくすると分かりやすい

今回この形に整理してみて、一番分かりやすかったのは「正解となる値が一つになる」ことだった。
たとえば順位については、持ち点だけが元データになる。

scores
↓
ranking
↓
currentSelfRank

点数を変更すれば、その結果として順位も変わる。
点数と順位を別々に更新する必要がない。
必要な和了点についても同じ。

scores
selfSeat
goal
honba
kyotaku
↓
必要なロン点・ツモ点

元になる入力値を変更すれば、結果もそこから求め直せる。
機能が増えて値の数が多くなっても、「どれが元データなのか」を意識すると少し整理しやすくなった。

まとめ

Reactで扱う値が増えてきたら、すべてをuseStateへ入れるのではなく、
ほかのstateやpropsから計算できないかを一度考える。
今回の麻雀アプリでは、

入力値
→ stateとして持つ

順位や必要点数など
→ 入力値から計算する

という形にした。
計算できる値までstateとして持つと、元データとの同期を自分で管理する必要が出てくる。
逆に、必要な元データだけをstateとして持てば、変更箇所を減らしやすい。
まだReactの状態管理については勉強中だけれど、今のところは、

「この値は本当に覚えておく必要があるのか?」

と考えるのが、stateを増やしすぎないための一つの目安になりそう。

最後にuseStateとuseMemoの簡単な比較を載せて〆。

useStateuseMemo
主な用途値を状態として保持する計算結果を再利用する
値が変わるきっかけsetStateで更新する依存する値が変わると再計算される
向いているもの入力値、選択状態など他の値から求められる計算結果
今回の例持ち点、自分の席、目標順位順位、必要点数、終局後の点数

牛一。

コメント

タイトルとURLをコピーしました