♟️

Recoil と将棋ったー

2023/01/27に公開

この記事は2023/1/20に開催された Harajuku.ts Meetup 〜 Recoilの事例集めました〜 の発表内容を元に執筆されています。

Recoilと将棋ったー
将棋対局サイト「将棋ったー」における、Recoilによる将棋盤面の状態管理の事例を紹介します。

実際の発表スライド

将棋ったーとは

https://shogitter.com

将棋ったーは、100種類以上の変則将棋のオンライン対局ができるサイトです。例えば量子将棋大局将棋将棋対囲碁、といったようなものがあります。以前バズったのは量子将棋というもので、駒が量子駒といってどの駒の動きか定まっておらず、動かすたびに可能性が狭まっていくというようなルールです。

主な技術スタックは次のとおりです。

  • Remix
  • React
  • Recoil

将棋ったーは10年以上前に作り始めたものですが、昨年上記のスタックでの書き換えをしたので、Recoilの経験を積むことができましたので、今回その経験を共有したいと思います。

フロントエンド状態管理の役割分担

将棋ったーのフロントエンドにおける状態管理は主に次のように分類できます。

React Router / Remix (URL)

ある状態遷移がページ遷移と見なせる場合は、URLを状態と見なすことができ、そのページで表示されるものはURL状態から派生されたデータとみなすことができます。この場合、派生データを状態として管理するな [1] の法則から、URLのみで状態管理をすべきです。

例えば、こちらの棋譜一覧ページには、ルールによるフィルタとページネーションといった状態がありますが、それらはURLに反映されており、使う側では単に読み込むためのhookが使われているだけです。

Remixについて軽く触れておきますと、Next.jsのような立ち位置にあるWeb Frameworkなのですが、React Routerを元にサーバとクライアントが協同して、素晴らしいUXをシンプルなコードや考え方で提供できるようになっています。そのうちの一つがFormloader/actionです。あたかも画面ごとにページ再読み込みをしていた古き良きMPAを作るようなメンタルモデルで、クライアントサイドでの遷移はfetchしてReactコンポーネントの一部再描画のみで行うといったProgressively EnhancedなSPA (PESPA) [2] を書くことができます。

Remixが管理してくれる状態について詳しく

loaderというのはあるroute(ページのようなもの)が読み込まれる際にサーバサイドで実行されるコードです。古き良きMPAを自然に書けるPHPを思い出してください:

kifu/index.php
// TODO: バリデーションすべき
<?php
$page = (int) $_GET["page"];
$rule = (int) $_GET["rule"];
$kifuList = getKifuList($rule, $page);

foreach(...$kifuList...) {
  // リストのHTMLを出力
} ?>
<a href="?rule=<?php echo $rule?>&page=<?php echo $page+1 ?></a>

このページと"次"というページネーションのリンクを使う際に次のようなことが起こります。

  1. ユーザが/kifu/?rule=0にアクセスする
  2. サーバがURLパラメータを読み、0ページ目に必要なデータを取得しHTMLを描画する。
  3. "次"というリンクをクリックすると/kifu/?rule=0&page=1というページを読み込む
  4. サーバがURLパラメータを読み、1ページ目に必要なデータを取得しHTMLを描画する。

こういった単純なMPAでは、フロントエンドには状態というものが存在しません。URLに対してそれに必要なデータをサーバサイドで読み込み、ページ全体のHTMLを返すだけです。

一方、Remixで同様の記述をしてみましょう。

routes/kifu/index.tsx
export const loader = async ({ request }: LoaderArgs) => {
  const { searchParams } = new URL(request.url);
  const rule = searchParams.get("rule");
  const page = searchParams.get("page") ?? 0;

  return {
    kifuList: await getKifuList(rule, page),
  };
};

export default function KifuIndex() {
  const { kifuList } = useLoaderData<typeof loader>();
  const location = useLocation();
  const searchParams = new URLSearchParams(location.search);
  const rule = searchParams.get("rule");
  const page = searchParams.get("page") ?? 0;

  return <>
    <KifuList data={kifuList} />
    <Link to={`?rule=${rule}&page=${page+1}`}></a>
  );
}

useLoaderDataはサーバで実行されたloaderの戻り値を取得するhook、useLocationは現在のURLに関する値を取得するhookです。MPAと同じように、あたかも「URLが唯一の状態であり、サーバが派生されたデータを返す」かのように見えます。実際のところ、これはSPA (PESPA)であり、次のような事が起こります。

  1. ユーザが/kifu/?rule=0にアクセスする
  2. サーバがURLパラメータを読み、0ページ目に必要なデータを取得し、HTMLをサーバサイドで描画する。さらに、その必要なデータとコンポーネントのコードをブラウザに送り、ハイドレーションを行う。
  3. "次"というリンクをクリックすると、Remixがhistoryに/kifu/?rule=0&page=1をpushし、そのURLに対応するloader dataをサーバに問い合わせ、サーバは1ページ目に必要なデータを取得して返し、ブラウザは新たなデータでクライアントサイド描画を行う

これが古き良きMPAのようなメンタルモデルで(PE)SPAが書け、結果として状態を管理する必要がなくなっている例です。

Remixのすごいところ[3]については公式ページをスクロールしていくと掴めるようになっていますので、ぜひチェックしてみてください。

Recoil

Recoilで将棋盤の状態管理を行いました。今回はこれについて詳しく説明します。

React の useState

その他の場合はReactのuseStateを使ってコンポーネントローカルな状態、あるいはlift upされた共通祖先コンポーネントで、複数コンポーネントで共有された状態を扱っています。将棋ったーでは幸いそれほどの状態はありません[4]

将棋盤を実装していこう

将棋盤のうち次の3つの基本的な機能について実装していきましょう。

  • 盤の表示
  • 駒の可動域の表示
  • 棋譜の再生

機能1: 盤の表示

将棋ったーで扱っている将棋の盤面は二次元[5]で、画面上に格子状に表示します。今回は持ち駒については省略します。

棋譜atomを定義

棋譜はこのような見た目になっています。

{
  board: [
    ["!香", "!桂",, "!桂", "!香"],
    ["",    "!飛",, "馬",  ""],["",    "",, "飛",  ""],
    ["香",  "桂","桂",  "香"],
  ],
  // mochigoma: (今回は省略),
  history: [
    "77->76",
    "33->34",
    "88->22 nari"
  ]
} satisfies Shogi

これに対応する棋譜は以下の通りで、初手から3手進んだものです(こちらで試せます)。最新の盤面が二次元配列でboardに入っています。

atomは状態を表すものでしたので、これをまるごと1つのatomに入れます。

states/shogiState.ts
// 棋譜状態
export const shogiState = atom<Shogi>({
  key: "shogiState",
  default: null,
})

これをデータフローグラフで表してみましょう。今の所ただの節点です。

棋譜atomを初期化

components/Game.tsx
// 盤コンポーネント
const Game = ({shogi}) => (
  <RecoilRoot initializeState={({set})=>{
    set(shogiState, shogi)
  }}>
    <Board />
  </RecoilRoot>
)

Recoilの状態を参照するコンポーネントを使用するには、<RecoilRoot>で囲むのでした[6]<RecoilRoot>initializeState prop内で状態の初期化を行うことができます。ちなみにSSRにも対応していて、ここで初期化した状態でサーバサイドでHTMLを描画できます。

棋譜atomの読み込み

Recoilの状態を読み込むにはuseRecoilValueを使うのでした。

components/Koma.tsx
// 駒コンポーネント
const Koma = ({x, y}: Pos) => {
  const {board} = useRecoilValue(shogiState)
  return <button>{board[x][y]}</button>
}

機能2: 駒の可動域の表示

次に、駒をクリックした際、駒の動ける範囲を盤上で表示するという機能を実装します。

掴んだ駒の座標atomを定義

派生データを状態として管理するな、ということを考慮すると、掴んだ駒の座標をatomとし、駒の可動域は盤面と掴んだ場所から派生させたselectorにすべきと考えられます。

states/movableState.ts
// 掴んだ座標
export const selectedPosState = atom<Pos>({
  key: "selectedPosState",
  default: null,
})

Posはこのような見た目です。

{x: 7, y: 6} satisfies Pos

掴んだ駒の可動域selectorを定義

盤面と掴んだ場所から派生させたselectorは、将棋のルールを知っているcalculateMovableを使ってこのように書けます。

states/movableState.ts
// 掴んだ駒の可動域リスト
export const movableState = selector<Pos[]>({
  get: ({ get }) => {
    const {board} = get(shogiState)
    const pos = get(selectedPosState)
    return calculateMovable(board, pos)
  }
})

現在のデータフローグラフはこの通りです。

掴んだ駒の可動域selectorの読み込み

selectorで定義された状態も、atomで定義された状態と同様に読み込めます。

components/Koma.tsx
 // 駒コンポーネント
 const Koma = ({x, y}: Pos) => {
   const {board} = useRecoilValue(shogiState)
+  const movables = useRecoilValue(movableState)
   return <button
+      style={isIn(movables, x, y)}>
     {board[x][y]}
   </button>
 }

掴んだ駒の可動域selectorの書き込み

components/Koma.tsx
 // 駒コンポーネント
 const Koma = ({x, y}: Pos) => {
   const {board} = useRecoilValue(shogiState)
   const movables = useRecoilValue(movableState)
+  const setSelectedPos = useSetRecoilState(selectedPosState)
   return <button
       style={isIn(movables, x, y)}
+      onClick={()=>setSelectedPos({x, y})}>
     {board[x][y]}
   </button>
 }

掴んだ駒を離すには?

ところで、駒を掴んだ後、離す方法がありません。もう一度同じ駒をクリックしても同じ駒を再度持つようになっています。これでは駒がネバネバして手にくっついたようで気持ち悪いですね。同じところをクリックしたら離すようにしてみましょう。

愚直案: コンポーネント内で実装

components/Koma.tsx
 // 駒コンポーネント
 const Koma = ({x, y}: Pos) => {
   const {board} = useRecoilValue(shogiState)
   const movables = useRecoilValue(movableState)
-  const setSelectedPos = useSetRecoilState(selectedPosState)
+  const [selectedPos, setSelectedPos] = useSetRecoilState(selectedPosState)
   return <button
       style={isIn(movables, x, y)}
-      onClick={()=>setSelectedPos({x, y})}>
+      onClick={()=>{
+        if(eq(selectedPos, {x, y}) {
+          setSelectedPos(null)
+        } else {
+          setSelectedPos({x, y})
+        }
+      }>
     {board[x][y]}
   </button>
 }

この変更については少し難点があります。

  • selectedPosを監視しないといけなくなった
  • 他で使うときに実装し忘れそう
  • コンポーネント内でゴチャゴチャやるのか?

改良案: selectorのsetter内で実装

こういったコンポーネント側への変更をする代わりに、Recoilのselectorの機能を使うことで同様の機能を実装することができます。真のatomをmodule-privateにし、selectedPosStateを真のatomをラップするselectorにします。これで、selectedPosStateを無秩序な変数ではなくて賢い状態にすることができます。

states/movableState.ts
// 掴んだ座標
-export const selectedPosState = atom<Pos>({
-  key: "selectedPosState",
+const _selectedPosState = atom<Pos>({
+  key: "_selectedPosState",
   default: null,
 })
+export const selectedPosState = selector<Pos>({
+  get: ({get}) => get(_selectedPosState),
+  set: ({get, set}, newPos) => {
+    if(eq(get(_selectedPosState), newPos))) {
+      set(null)
+    } else {
+      set(newPos)
+    }
+  }
+}

使う側はそのままで済みます。

駒コンポーネント(再掲)
components/Koma.tsx
// 駒コンポーネント (当初から変更なし)
const Koma = ({x, y}: Pos) => {
  const {board} = useRValue(shogiState)
  const movables = useRValue(movableState)
  const setSelectedPos = useSetRecoilState(selectedPosState)
  return <button
      style={isIn(movables, x, y)}
      onClick={()=>setSelectedPos({x,y})}>
    {board[x][y]}
  </button>
}

機能3: 棋譜の再生

最後に、棋譜の再生機能を実装しましょう。これは、最初の盤面からこれまでどのように駒が動いたかを再現できる機能で、進む・戻るボタンや数字の入力フィールドで変更できます。

手数atom、再生後の盤面selectorを定義

states/shogiState.ts
 // 棋譜
 export const shogiState = atom<Shogi>({
   key: "shogiState",
   default: null,
 })
+// 手数
+export const tesuuState = atom<number>({
+  key: "tesuuState",
+  default: 0
+})
+// boardがtesuu手目の状態になった棋譜状態
+export const replayedShogiState = selector<Shogi>({
+  get: ({get}) => {
+    const shogi = get(shogiState)
+    const tesuu = get(tesuuState)
+    return calcReplayed(shogi, tesuu)
+  }
+})

再生後の盤面selectorの読み込み

さて、このreplayedShogiStateを使うのはどこかと考えてみましょう。ほぼすべての場合ということが想像されました。

  • 盤面を表示するとき
  • 駒を掴むとき
  • 掴んだ駒の可動域を計算するとき

愚直案: 既存のshogiState使用箇所を変更する

では、コンポーネント内と他のselector内のshogiStateを使っている箇所をすべて変更すべきなのでしょうか?

components/Koma.tsx
 // 駒コンポーネント
 const Koma = ({x, y}: Pos) => {
-  const {board} = useRecoilValue(shogiState)
+  const {board} = useRecoilValue(replayedShogiState)
   return <button>{board[x][y]}</button>
 }

改良案: replayedShogiStateshogiStateと変名する

states/shogiState.ts
-// 棋譜状態
-export const shogiState = atom<Shogi>({...})
+// 最新の盤面を持った棋譜状態
+export const latestShogiState = atom<Shogi>({...})
 // 手数
 const tesuuState = atom<number>({...})
 // boardがtesuu手目の状態になった棋譜状態
-export const replayedShogiState = selector<Shogi>({
+export const shogiState = selector<Shogi>({
   get: ({get}) => {
-   const shogi = get(shogiState)
+   const shogi = get(latestShogiState)
    const tesuu = get(tesuuState)
    return calcReplayed(shogi, tesuu)
  }
})

手数atomへの書き込み

手数のatomへ不正な値を書き込めては困ります。真のatomをラップし、set内で検査することでvalidationを必須化することができます。

states/shogiState.ts
 // 棋譜状態
 export const shogiState = atom<Shogi>({...})
 // 最新の盤面を持った棋譜状態
 export const latestShogiState = atom<Shogi>({...})
 // 手数
-export const tesuuState = atom<number>({default: 0})
+const _tesuuState = atom<number>({default: 0})
+export const tesuuState = selector<number>({
+  get: ({get})=> get(_tesuuState),
+  set: ({set, get}, newTesuu) => {
+    const {history} = get(latestShogiState)
+    if(0>newTesuu || history.length-1 > newTesuu)
+      return // setしない
+    set(_tesuuState, newTesuu)
+  }
+})

tesuuへ書き込む側のコンポーネントコードは省略しますが、atomへの書込みと同様にuseSetRecoilStateを使うだけです。

最新盤面への追従

他にも細かい機能として、latestShogiに新しい手が来た際、もし再生機能で最新の盤面を表示した状態だったら、さらに最新の盤面を追いかけるというものを考えます。tesuuが固定した数値の場合、最新の盤面が来ても再生後に同じ盤面を表示し続けてしまいます。

これは、tesuuに内部的にInfinityを持ち、getで有効な値にnormalizeする方針で、コンポーネントコードを触らずに実現できます。

states/shogiState.ts
 // 最新の盤面を持った棋譜状態
 export const latestShogiState = atom<Shogi>({...})
 // 手数
-const _tesuuState = atom<number>({default: 0, ...})
+const _tesuuState = atom<number>({default: Infinity, ...})
 export const tesuuState = selector<number>({
-  get: ({get})=> get(_tesuuState),
+  get: ({get})=> Math.min(
+    get(_tesuuState),
+    get(latestShogiState).history.length-1))
  set: ({set, get}, newTesuu) => {
    const {history} = get(latestShogiState)
-    if(0>newTesuu || history.length-1 > newTesuu)
-      return // setしない
-    set(_tesuuState, newTesuu)
+    if(0>newTesuu)
+      return // setしない
+    set(_tesuuState,
+      history.length-1 <= newTesuu
+      ? Infinity : newTesuu)
  }
})

その他Recoilの良いところについて

直接は出てきませんでしたが、この他にRecoilを使用していいと感じた点について紹介します。

Recoil の良いところ2 - APIが簡潔

  • Reactの状態hooksと似たようなメンタルモデルで書ける
    • useState, useCallback
    • useRecoilState, useRecoilCallback
  • 大きなフォントを使ったスライドに収まるくらいボイラープレートが少ない
  • atom, selectorのような変数的なものは変数で定義するので、直感的(スコープはグローバルではなくRecoilRootごとではあるが)

Recoil の良いところ3 - ボトムアップ(モデル先行)

  • Recoil
    • ボトムアップで概念のモデルを構築し、使うところでhookする
    • IMHO: モデルがさき、使う側があと
    • 局所性も持たせられる。 e.g. 複数の将棋盤を同時に表示
      • <RecoilRoot>ごとに違う状態を持たせられる
  • Redux

Fetch系ライブラリやReact Router/Remixなど、特定の状態を巧妙に管理してくれるライブラリは多い。ボトムアップのほうが状態管理の適材適所に向いているのではないかと考えます。

おまけ(事前に寄せられた質問)

Q: `useRecoilCallback`を使う時は?

A: useCallbackを使う時 && callback内でRecoil状態を読み書きする時

useCallbackを使う時は?

  • Usage
    • Skipping re-rendering of components
    • Updating state from a memoized callback
    • Preventing an Effect from firing too often
    • Optimizing a custom Hook

(useCallback 公式docより)

callback内でRecoil状態を読み書きする時

これもそのままです。ただし:

  • selectorのset内でgetすることもできるので、callback内でRecoil状態を読みつつ書き込む必要はないかも
  • selectorの定義にするほど共通の処理ではない場合に使えるかも

先程のKomaコンポーネントの例

// 駒コンポーネント
const Koma = ({x, y}: Pos) => {
  ...
  const setSelectedPos = useSetRecoilState(selectedPosState)
  return <button ...
      onClick={()=>setSelectedPos({x, y})}>
    {board[x][y]}
  </button>
}

駒を掴む場合に使いたいかというと、特に使いたい場面ではないことが確認できます。

  • useCallbackを使っても最適化にはなるわけでもないのでしょうがない
  • ❎ callback内でRecoil状態を読むわけではない

将棋ったーの実際の例

将棋ったーで実際にあったコミットをご紹介します。

これは駒の着手に関わるhookなのですが、変更前はたくさんのuseRecoilValue()がrender時に実行されるようになっており、このhookを使用するコンポーネントがそれらすべての状態を監視しなくてはなりません。しかし、多くの値はコールバックが呼ばれる時、この場合は駒を動かそうとした時しか必要ではありません。一方変更後ではuseRecoilCallbackの中に状態の読み込みコードが移動しており、不要な監視がなされなくなっています。

まとめ: Recoilのパターンと良いところ

  • Recoil パターン 1: カプセル化
    • atomをselectorでラップすることで、状態の更新に関するロジックをカプセル化できる。atomをexportしなければそれを必須化できる。
  • Recoil パターン 2: validation
    • カプセル化の後、set時に値を検査しはじく
  • Recoil パターン 3: normalization
    • カプセル化の後、get時に値を検査し調整する
  • Recoil のいいところ 1: 状態のリファクタリングがしやすい
    • atom・selectorの区別なく、変数とkeyの変名だけでリファクタリングできる
  • Recoil のいいところ 2: APIが簡潔
  • Recoil のいいところ 3: ボトムアップ(モデル先行)
脚注
  1. https://blog.webdevsimplified.com/2019-11/never-store-derived-state/ ↩︎

  2. https://www.epicweb.dev/the-webs-next-transition#progressively-enhanced-single-page-apps-pespas ↩︎

  3. 以前ブログで軽く取り上げました。(Nested Routesはその後Next.jsにも取り入れられることになりました) https://na2hiro.hatenablog.com/entry/2022/01/31/Remix_がすごい_%26_`remix-auth-twitter`_を_publish_した ↩︎

  4. リポジトリ全体でuseStateは9箇所しか出現しません ↩︎

  5. 世の中には、三次元の宇宙将棋というものもあるようです ↩︎

  6. 状態はそれぞれの<RecoilRoot>ごとに独立して管理されます ↩︎

Discussion