開発日記⑩ Reactのカスタムフックで状態管理と計算処理を整理する

前回までの進捗

前回は、Vitestを使って順位計算やロン・ツモ・流局などのテストを追加した。
ここまでで機能自体はかなり増えてきたものの、そのぶんReact側で管理する値や処理も増えてきた。
点数、自分の席、目標順位、本場、供託、終局方法、和了者、放銃者……と、useStateだけでもかなりの数になる。
さらに、

  • 入力内容の自動保存
  • 条件のランダム生成
  • 入力内容のリセット
  • 必要なロン・ツモ点の計算
  • 終局後の点数計算
  • 順位計算
  • 目標達成判定

などもある。
これらを全部App.tsxへ書いていくと、画面を組み立てるコードと計算処理が混ざってかなり読みにくくなりそう。
そこで今回は、Reactのカスタムフックを使って処理を整理した。

カスタムフックとは

Reactでは、useStateやuseEffectなどを組み合わせた処理を、自分で関数としてまとめることができる。
こうした関数をカスタムフックと呼ぶ。・・・らしい。
このあたりは初見のため、またどこかで知識整理もかねて記事にしたいところ。
名前はuseから始める。
今回作っているのは主に次の2つ。

useGameState
useGameDerivedState

ざっくり分けると、

useGameState
→ 入力値や変更処理を管理する

useGameDerivedState
→ 入力値から計算できる結果を管理する

という役割にした。

入力状態はuseGameStateへまとめる

まず、ユーザーが入力した値はuseGameStateへまとめた。
たとえば、

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

const [selfSeat, setSelfSeat] =
  useState<Seat>(...);

const [goal, setGoal] =
  useState<Goal>(...);

const [honba, setHonba] =
  useState<number>(...);

といった状態。
ほかにも、

  • 難易度
  • 供託
  • 終局方法
  • 和了者
  • 放銃者
  • ロン点
  • ツモ時の支払い
  • テンパイ者

などをここで管理する。
画面から変更できる値は、基本的にこちらへ集めた。

保存処理もuseGameStateで行う

前に追加したlocalStorageへの自動保存も、このカスタムフックの中にある。

useEffect(() => {
  const stateToSave: SavedGameState = {
    scores,
    selfSeat,
    goal,
    difficulty,
    honba,
    kyotaku,
    resultType,
    winner,
    loser,
    ronPoints,
    dealerPayment,
    childPayment,
    tenpaiSeats,
  };

  saveGameState(stateToSave);
}, [
  scores,
  selfSeat,
  goal,
  difficulty,
  honba,
  kyotaku,
  resultType,
  winner,
  loser,
  ronPoints,
  dealerPayment,
  childPayment,
  tenpaiSeats,
]);

入力値のどれかが変わると保存される。
保存データの読み込みもuseGameState側で行うため、App.tsx側では保存処理を意識しなくてよくなった。

ランダム生成やリセットもまとめる

条件のランダム生成も、状態を変更する処理なのでuseGameStateへ入れた。

function randomizeCondition(): void {
  const generatedCondition =
    generateCondition(difficulty);

  if (generatedCondition === null) {
    return;
  }

  setScores(generatedCondition.scores);
  setSelfSeat(generatedCondition.selfSeat);
  setGoal(generatedCondition.goal);
  setHonba(generatedCondition.honba);
  setKyotaku(generatedCondition.kyotaku);
}

初期状態へ戻す処理も同じ。

function resetSavedState(): void {
  clearSavedGameState();

  setScores({
    ...initialGameState.scores,
  });

  setSelfSeat(initialGameState.selfSeat);
  setGoal(initialGameState.goal);

  // ほかの入力値も初期化
}

どちらも複数のuseStateをまとめて変更するため、画面側へ直接書くより一か所にまとめた方が分かりやすい。

計算結果はuseGameDerivedStateへ分ける

一方、ユーザーが直接入力するわけではなく、入力値から計算できるものはuseGameDerivedStateへまとめた。
たとえば終局後の点数。

const finalScores = useMemo(() => {
  if (resultType === "ron") {
    return applyRon(
      scores,
      winner,
      loser,
      ronPoints,
      honba,
      kyotaku,
    );
  }

  if (resultType === "tsumo") {
    return applyTsumo(
      scores,
      winner,
      dealerPayment,
      childPayment,
      honba,
      kyotaku,
    );
  }

  return applyDraw(
    scores,
    tenpaiSeats,
  );
}, [
  scores,
  resultType,
  winner,
  loser,
  ronPoints,
  dealerPayment,
  childPayment,
  honba,
  kyotaku,
  tenpaiSeats,
]);

入力した終局方法によって処理を切り替え、終局後の点数を求める。
このfinalScores自体をuseStateで持つのではなく、元になる入力値から計算する形にしている。

順位や必要点数も計算する

終局後点数が分かれば、そこから順位も計算できる。

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

さらに、

  • 現在の順位
  • 自分の現在順位
  • 目標達成済みか
  • 必要なロン点
  • 必要なツモ点
  • 最低和了点
  • 終局後に目標達成できたか

などもuseGameDerivedStateで計算する。
こうして見ると、useGameStateとの違いが分かりやすい。

ユーザーが変更する値
↓
useGameState

入力値から求められる値
↓
useGameDerivedState

という分け方になった。

App.tsxは画面を組み立てる役割にする

2つのカスタムフックを作ったことで、App.tsxではまず必要な値を受け取る。

const {
  scores,
  selfSeat,
  goal,
  honba,
  kyotaku,
  updateScore,
  randomizeCondition,
} = useGameState();

次に、その値を計算用のフックへ渡す。

const {
  finalScores,
  ranking,
  requiredRonList,
  requiredTsumo,
  success,
} = useGameDerivedState({
  scores,
  selfSeat,
  goal,
  honba,
  kyotaku,
  // ほかの入力値
});

あとは、それぞれの画面へ必要な値を渡す。

<ScoreInputSection
  scores={scores}
  onChangeScore={updateScore}
/>

<RequiredPointsSection
  requiredRonList={requiredRonList}
  requiredTsumo={requiredTsumo}
/>

<ResultSummarySection
  finalScores={finalScores}
  ranking={ranking}
  success={success}
/>

App.tsxにもまだ渡している値は多いけれど、

「状態を管理する」
「点数を計算する」
「画面を表示する」

という役割は以前より分けやすくなった。

状態と計算結果を分けてみて

最初は、必要な値を全部useStateで持てばいいと思っていた。
ただ、今回整理してみると、同じ「値」でも少し種類が違う。
たとえば現在の点数はユーザーが入力するので状態として持つ必要がある。
一方、現在順位は点数があれば計算できる。

点数
↓
順位

なので、点数と順位を両方別々の状態として持つ必要はない。
必要な元データだけを状態として持ち、そこから求められるものは計算する。
この考え方は、今後Reactで機能を増やすときにも使えそう。

今回できたこと

今回の進捗は以下のとおり。

  • Reactの状態管理をuseGameStateへまとめた
  • 保存データの読み込みと自動保存をまとめた
  • ランダム条件生成を状態管理側へ移した
  • 入力内容のリセット処理をまとめた
  • 計算結果をuseGameDerivedStateへ分けた
  • 終局後点数と順位の計算を整理した
  • 必要なロン点・ツモ点の計算を整理した
  • App.tsxを画面を組み立てる役割へ寄せた

新しい機能を追加した回ではないので、画面上の変化はほぼない。
それでも、今後さらに処理を増やしていくことを考えると、早めに整理しておいてよかったと思う。
コードが増えてくると、とりあえず動かすだけでは少しずつ厳しくなってくる。
次は、入力ミスや成立しない終局結果を判定するために作った、バリデーション処理についてまとめる。

今回も画面は特に変わりなし。牛一。

コメント

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