🕔

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

2022/04/16に公開

はじめに

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

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/

お題:CRUD

(以下DeepL翻訳)

課題:ドメインロジックとプレゼンテーションロジックの分離、変異の管理、自明でないレイアウトの構築。
タスクは、次の要素を含むフレームを構築することです:テキストフィールドTprefix、テキストフィールドTnameとTsurnameのペア、リストボックスL、ボタンBC、BU、BD、スクリーンショットに見られるような3つのラベルです。Lは、名前のリストからなるデータベースのデータのビューを表示します。一度に選択できる項目は1つだけです。Tprefixに文字列を入力すると、入力した接頭辞で始まる姓の名前をフィルタリングすることができます - これは接頭辞をenterで入力しなくてもすぐに実行されるはずです。BCをクリックすると、TnameとTsurnameの文字列を連結した結果の名前がLに追加されます。BCとは異なり、BUは結果の名前を追加せず、選択中の項目を新しい名前に置き換えます。BDは、選択した項目を削除します。レイアウトは、スクリーンショットに示したように行います。特に、Lは残りのスペースをすべて占めなければならない。
CRUD(Create、Read、Update、Delete)は、典型的なグラフィカルなビジネスアプリケーションを表しています。主な課題は、ソースコードにおけるドメインとプレゼンテーションのロジックの分離で、プレフィックスでビューをフィルタリングできるため、多かれ少なかれ実装者に強制されることになります。従来は、ドメインロジックとプレゼンテーションロジックの分離を実現するために、何らかの形でMVCパターンが使用されてきました。また、名前のリストの変異を管理するアプローチもテストされている。良い解決策は、ドメインロジックとプレゼンテーションロジックをあまりオーバーヘッドなく(例えば、ツールキット固有の概念や言語/パラダイム概念の形で)分離し、高速だがエラーが起こりにくい変異管理、レイアウトの自然な表現(もちろんレイアウトビルダーは許されるがオーバーヘッドが増加するだろう)であろう。
CRUDは、ブログ記事「FRP - 双方向のデータフローを持つGUI要素のための3つの原則」にあるcrudの例から直接インスピレーションを得たものです。

回答コード

解説

定義Atomを確認

UIで見えるもの一つ一つがatomで定義されています。大体は1対1で紐づくのでそこはシンプルに受け止めることができそうです。

  • nameAtom, surnameAtom: 姓名(Surname, Name)の入力用。新規作成の他に更新時にも利用
  • nameListAtom: 扱う人名のリスト。7GUIs初のatoms in atomで表現されています。exportされていません。Base atomとして扱われています。
  • filteredNameListAtom: UIで人名のリストで表示しているのは、nameListAtomではなくこちらのatomです。名前の通り、フィルター機能を担保しています。
  • prefixAtom: フィルター用のprefix文字列を扱います。filteredNameListAtomから参照されます。
  • baseSelectedAtom, selectedAtom: リスト上の人名を選択したときの対象人名を扱います。選択時に姓名を表示するためbase atomを使って対応しています。
  • createAtom: 新規追加用です。追加の可否をread関数(atomの値)で表現しています。
  • updateAtom: 更新用です。更新の可否をread関数(atomの値)で表現しています。
  • deleteAtom: 削除用です。削除の可否をread関数(atomの値)で表現しています。

Atoms in atom とは

これまで、atomで扱う値にはboolean, string, numberなどprimitiveなものが多かったと思いますが、atom自体も値として扱うことが可能です。Jotaiでは、atomの中にatomを定義するものをatoms in atomと呼びます。

コチラの記事ではナゼそうするのかを焦点に紹介していますのでご参考までに。
https://zenn.dev/tell_y/articles/c87725c97434be

CRUD編では、atomを値にもつ配列がatomとして登場します。
nameListAtomは、[atom({name: '...', surname: '...'}), atom({name: '...', surname: '...'}), atom({name: '...', surname: '...'}),,,]のようになります。

type NameItem = { name: string; surname: string };
export type NameItemAtom = PrimitiveAtom<NameItem>;

const nameListAtom = atom<NameItemAtom[]>([]);

型を定義する場合は、PrimitiveAtomを使います。

read関数/write関数での取り扱い

nameListAtomはatoms in atomです。nameList = get(nameListAtom)とすることでnameListは[atom(...), atom(...),,,]を持つことになります。nameList.filter()部分を見て分かるように、さらにget()で値を参照することが出来ます。

export const filteredNameListAtom = atom((get) => {
  const prefix = get(prefixAtom);
  const nameList = get(nameListAtom);
  if (!prefix) {
    return nameList;
  }
  return nameList.filter((nameItemAtom) =>
    get(nameItemAtom).surname.startsWith(prefix)
  );
});

write関数内でatom()を定義してsetすることが出来ます。nameItemAtomをnameListAtomに追加しています。

export const createAtom = atom(
  (get) => !!get(nameAtom) && !!get(surnameAtom),
  (get, set) => {
    const name = get(nameAtom);
    const surname = get(surnameAtom);
    if (name && surname) {
      const nameItemAtom: NameItemAtom = atom({ name, surname });
      set(nameListAtom, (prev) => [...prev, nameItemAtom]);
      set(nameAtom, "");
      set(surnameAtom, "");
      set(selectedAtom, null);
    }
  }
);

deleteAtomでは削除対象のatomをfilterして新たにnameListAtomをsetしていますが、atomとatomを比較しています。
これは少し奇妙に感じるかもしれませんが、可能です。一般的にJSではobject同士の比較はご法度のように語られますが、今回のようにnameListAtomのもつ要素=objectの参照が比較可能なのでこのように出来ます。

export const deleteAtom = atom(
  (get) => !!get(selectedAtom),
  (get, set) => {
    const selected = get(selectedAtom);
    if (selected) {
      set(nameListAtom, (prev) => prev.filter((item) => item !== selected));
    }
  }
);

リスト機能

filteredNameListAtomはatoms in atomでした。それからuseAtom()でlistを得ます。listの各要素がatomです、map内のitemですね。
姓名を1行ずつ表示するためにItemコンポーネントに値を渡さなければなりませんが、atomをそのまま渡します。keyにはString(item)${item}とできます。

const List = () => {
  const [list] = useAtom(filteredNameListAtom);
  return (
    <div
      style={{
        width: "100%",
        height: "8em",
        overflow: "scroll",
        border: "2px solid gray"
      }}
    >
      {list.map((item) => (
        <Item key={String(item)} itemAtom={item} />
      ))}
    </div>
  );
};

受け取ったatomをItemコンポーネントで扱います。
姓名を表示するだけであればuseAtom(itemAtom)とするだけです。
選択操作のために少し工夫がしてあります。選択動作だけであればもっとシンプルに書けますが、選択中(selected)を表現するためにコンポーネント内でatomを作っています。
atomをコンポーネント内で作る場合、レンダリング時に都度atomが作られないようにuseMemoを使います。

選択中をbooleanで表します。上記で述べたようにatomの参照比較です。

get(selectedAtom) === itemAtom

Write関数ではselectedAtomに選択したitemAtomをsetします。

const Item = ({ itemAtom }: { itemAtom: NameItemAtom }) => {
  const [{ name, surname }] = useAtom(itemAtom);
  const [selected, setSelected] = useAtom(
    useMemo(
      () =>
        atom(
          (get) => get(selectedAtom) === itemAtom,
          (_get, set) => set(selectedAtom, itemAtom)
        ),
      [itemAtom]
    )
  );
  return (
    <div
      style={{
        padding: "0.1em",
        backgroundColor: selected ? "lightgray" : "transparent"
      }}
      onClick={setSelected}
    >
      {name}, {surname}
    </div>
  );
};

新規追加機能について(createAtom, CreateButton)

createAtomの値は新規追加の可否に利用されます。新規追加実行時の処理はcreateAtomのwrite関数に押し込めてあります。[enabled, create]のように実行可否値と実行関数のパターンはおなじみになってきました。

const CreateButton = () => {
  const [enabled, create] = useAtom(createAtom);
  return (
    <button disabled={!enabled} onClick={create}>
      Create
    </button>
  );
};

Read関数から返す値はboolean値です。新規追加の可否は姓名が入力済みかどうかで決めます。
Write関数では姓名を取得し、それらからatom({ name, surname })を作りnameListAtomに追加しています。atoms in atomです。
追加後は各atomを初期化しています。

export const createAtom = atom(
  (get) => !!get(nameAtom) && !!get(surnameAtom),
  (get, set) => {
    const name = get(nameAtom);
    const surname = get(surnameAtom);
    if (name && surname) {
      const nameItemAtom: NameItemAtom = atom({ name, surname });
      set(nameListAtom, (prev) => [...prev, nameItemAtom]);
      set(nameAtom, "");
      set(surnameAtom, "");
      set(selectedAtom, null);
    }
  }
);

更新機能について(updateAtom, UpdateButton)

CreateButton コンポーネントと同じ形をしています。

const UpdateButton = () => {
  const [enabled, update] = useAtom(updateAtom);
  return (
    <button disabled={!enabled} onClick={update}>
      Update
    </button>
  );
};

Read関数から返す値はboolean値です。更新の可否は姓名の入力済み、選択済みかどうかで決めます。
Write関数では姓名、選択したatomを取得し、更新処理をします。

export const updateAtom = atom(
  (get) => !!get(nameAtom) && !!get(surnameAtom) && !!get(selectedAtom),
  (get, set) => {
    const name = get(nameAtom);
    const surname = get(surnameAtom);
    const selected = get(selectedAtom);
    if (name && surname && selected) {
      set(selected, { name, surname });
    }
  }
);

削除機能(deleteAtom, DeleteButton)

こちらもCreateButton コンポーネントと同じ形をしています。

const DeleteButton = () => {
  const [enabled, del] = useAtom(deleteAtom);
  return (
    <button disabled={!enabled} onClick={del}>
      Delete
    </button>
  );
};

updateAtomと同じ形ですね。

export const deleteAtom = atom(
  (get) => !!get(selectedAtom),
  (get, set) => {
    const selected = get(selectedAtom);
    if (selected) {
      set(nameListAtom, (prev) => prev.filter((item) => item !== selected));
    }
  }
);

フィルター機能(prefixAtom, Filter)

prefixAtomとFilterコンポーネントは至ってシンプルです。フィルターするための文字列を扱います。

export const prefixAtom = atom("");
const Filter = () => {
  const [prefix, setPrefix] = useAtom(prefixAtom);
  return (
    <div>
      <span>Filter prefix:</span>
      <input value={prefix} onChange={(e) => setPrefix(e.target.value)} />
    </div>
  );
};

filteredNameListAtomのread関数でフィルタリングします。nameListはatomを要素に持ちますのでfilter関数内で都度get(nameItemAtom)しています。

export const filteredNameListAtom = atom((get) => {
  const prefix = get(prefixAtom);
  const nameList = get(nameListAtom);
  if (!prefix) {
    return nameList;
  }
  return nameList.filter((nameItemAtom) =>
    get(nameItemAtom).surname.startsWith(prefix)
  );
});

おわりに

今回のお題ではatoms in atom、useMemoを使ったコンポーネント内でのatom作成と使用が登場しました。
Atoms in atomは再レンダリングを抑えるために必須のテクニックですが、巨大なデータの更新処理を扱いやすくしてくるメリットもあります。
コンポーネント内でのatom作成は発想や慣れも必要なので、ひとまずは頭の片隅に入れておく程度でも良いかと思います。

Jotai Friendsとは

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

https://jotaifriends.dev/

Jotai Friends

Discussion