🕖

7GUIsで学ぶReact状態管理Jotai | Cells 編 (7/7)

2022/07/14に公開

はじめに

この記事は、「GUIプログラミングのベンチマークとして提案された7つの課題を題材に、React状態管理ライブラリのJotaiを学んでみよう」というテーマのJotai学習記事の第七回Cells編です。
完成したコードの解説がメインになります。もしご自身で実装してみたい場合はネタバレになってしまうのでご注意ください。

7GUIsとは

言語やライブラリ系のベンチマークといえば計算速度が評価軸にされることが一般的ですが、この7GUIsはいくつかの指標を軸に7つGUIアプリのお題を用意し、それをベンチマークとして提案しています。
2018年頃に話題となったようで、web系だとReact/MobXやSvelteが実装例として掲載されています。
詳しくはこちら。
https://eugenkiss.github.io/7guis/

7GUIs x Jotai の元ネタ

Jotai作者の@dai_shiさんが過去に取り組んでおり、CodeSandboxで既に実装済みなのでそちらを題材として使わせていただきます。
https://blog.axlight.com/posts/learning-react-state-manager-jotai-with-7guis-tasks/

お題:Cells

(以下DeepL翻訳)

課題:変更の伝達、ウィジェットのカスタマイズ、より本格的な/関与したGUIアプリケーションの実装。
課題は、シンプルだが使い勝手の良い表計算アプリケーションを作成することである。表計算ソフトはスクロール可能でなければなりません。セルCをダブルクリックすると、Cの数式を変更することができます。編集が終わると、数式が解析・評価され、その更新値がCに表示されます。さらに、Cに依存しているすべてのセルを再評価する必要があります。この処理は、どのセルの値にも変化がなくなるまで繰り返される(変化の伝播)。すべてのセルの値を再計算するのではなく、他のセルの変更された値に依存するセルの値だけを再計算する必要があることに注意してください。もし、すでに提供されている表計算ウィジェットがあれば、それを使うべきではない。代わりに、別の似たようなウィジェット(SwingのJTableのような)をカスタマイズして、再利用可能なスプレッドシートウィジェットにする必要があります。
Cellsは、特定のアプローチがある程度大きなアプリケーションに対応できるかどうかをテストする、より本格的で複雑なタスクです。GUI関連の主な課題は、変更のインテリジェントな伝播とウィジェットのカスタマイズの2つです。確かに、必ずしもGUIに関連しない部分もかなりありますが、それはより本格的な課題の性質に他なりません。良いソリューションであれば、変更の伝達はそれほど苦労しませんし、ウィジェットのカスタマイズもそれほど難しくないはずです。ドメイン固有のコードは、GUI固有のコードと明確に分離されています。出来上がったスプレッドシートウィジェットは再利用可能です.
Cellsは、Programming in ScalaのSCellsという表計算ソフトに直接インスパイアされています。特に、数式の解析や評価、表計算言語の正確な構文やセマンティクスなど、直接GUIに関係しない部分については、この本(またはこのリポジトリにある実装)を参照してください。

回答コード

解説

Daishiさんがブログで言われているように、興味深く&驚くべきことに、コード量はかなり少なくなっています。evalを使ってチートしたとのことですが、課題に対する本質では無い部分なのでjotaiの学習目的には些細なことでしょう。

atomFamilyを使ってセル1つ1つをatomで定義

atomFamilyの紹介

今回はutilsのatomFamilyが使われています。atomFamily関数は、atomを返す関数を作る関数です。
もしその関数がaというatomをすでに作っていたらそれ(キャッシュ)を返し、新しければ新しくatomを作ります。

詳しくは公式ドキュメントを。
https://jotai.org/docs/utils/atom-family

そうはいっても、atomFamilyの使い方を知らないと今回の実装を読むには辛さがあるので簡単に紹介します。

以下はドキュメントにあるコードです。todoFamilyは (name: string) => atom(name) でatomを返す関数です。なので、todoFamily('foo')は、atom('foo')"なければ新たに作って"返し、"存在すればそれを"返します。

import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'

const todoFamily = atomFamily((name: string) => atom(name))

const fooAtom = todoFamily('foo')
// this will create a new atom('foo'), or return the one if already created

todoFamily(...)に与える引数がMapのキーになります。(atomFamilyのMapを使っている実装コードはこちら)

atomFamily((name: string) => atom(name))を見てわかるように、atomを返す際にキーを引数として受け取ることができるんですが、実は使わなくてもよいのです。(なんてこったい)

atomFamilyの仕様を踏まえてCellsの実装コードを見る

atomFamilyの仕様を踏まえてコードを見てみましょう。

base atom と derived atom の要領で、baseCellFamily と cellFamily が用意されています。

cellFamilyでスプレッドシートのセル1つ1つにatomを用意してあげます。
baseCellFamily(cellId)でセル用のatomを用意しますが、セルの中身はstringで初期値は空文字でatomを定義します。
それがconst baseCellFamily = atomFamily(() => atom(""));() => atom("")になります。cellIdは、不要なので使ってません。(内部のMapで)参照するためだけに使います。

import { atomFamily } from "jotai/utils";

const baseCellFamily = atomFamily(() => atom(""));

...

export const cellFamily = atomFamily((cellId: string) =>
  atom(
    (get) => {
      const exp = get(baseCellFamily(cellId));
      const val = evalCell(exp, (cellId) => get(cellFamily(cellId)).val);
      return { exp, val };
    },
    (_get, set, exp: string) => {
      set(baseCellFamily(cellId), exp);
    }
  )
);

はい、ここまでくれば(atomFamilyの使い方さえわかれば)、後はシンプルではないでしょうか。
冒頭で述べたように、「セル内の数式を扱うためにevalを使った」とあるように、evalCell関数がそれにあたります。

evalCell関数は本筋から逸れるので本記事では解説を省略します。

ということでatomに関する定義は以上です。(少ない!)

Components

スプレッドシートのセルはtableで組まれています。重要なのはセルのコンポーネント(Cell)なのでそれを見ましょう。

propsのidはセルの行と列の番号で作られた文字列です。それをキーにしてatomを生成(またはキャッシュから取得)しています。(レンダリングが走るたびにatomを生成せずにキャッシュから得ているということですね)

const [{ exp, val }, setExp] = useAtom(cellFamily(id));

はい、あとはatomの値({ exp, val })を入力中にはexpを出し、そうでなければvalを表示、入力が確定(onDone)したときにatomに値をセット(setExp)すれば以上です。(短い!)

const Cell = ({ id }: { id: string }) => {
  const [editing, setEditing] = useState(false);
  const [{ exp, val }, setExp] = useAtom(cellFamily(id));
  const onDone = (e: any) => {
    setExp(e.target.value);
    setEditing(false);
  };
  const onKeyPress = (e: any) => {
    if (e.key === "Enter") {
      onDone(e);
    }
  };
  return (
    <td onClick={() => setEditing(true)}>
      {editing ? (
        <input
          defaultValue={exp}
          autoFocus
          onBlur={onDone}
          onKeyPress={onKeyPress}
        />
      ) : (
        val
      )}
    </td>
  );
};

おわりに

7GUIs最後のお題がとても記述量少なく終わってしまいました。もちろん、エクセルのように多機能ではありませんがお題はクリアできており十分ではないでしょうか。

atomFamilyの便利さもありましたが、注意点としてはこのutil関数はメモリリークに注意して扱う必要があります。Mapなので使わなくなったキー・バリューは消さない限り残り続けます。(ドキュメントでの言及はコチラ

7GUIsすべてのお題でJotaiのすべてが駆使されたわけではありませんが、Jotai流を知るには良いものだと捉えています。

Daishiさん、そして目を通してくださった皆さん、ありがとうございました!

これまでの"7GUIsで学ぶReact状態管理Jotai"一覧

https://zenn.dev/tell_y/articles/6436b8afa724a5

https://zenn.dev/tell_y/articles/769d171804e059

https://zenn.dev/tell_y/articles/4a431151dbe217

https://zenn.dev/tell_y/articles/644674c79fb431

https://zenn.dev/tell_y/articles/4216470d4f5254

https://zenn.dev/tell_y/articles/84c952d2580e1b

(顔がうざい)

Jotai Friendsとは

いちJotaiファンとして、エンジニアの皆さんにもっとJotaiを知ってもらって使ってもらいたい、そんな思いから立ち上げたのがJotai Friendsです。

https://jotaifriends.dev/

Jotai Friends

Discussion