かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React 軽量状態管理ライブラリ👻 jotai 👻 さわってみた!

普段 React の状態管理には Recoil を愛用しています
jotai という Recoil ライクな軽量ライブラリがあると聞いたので試してみました

👻 jotai

Recoil vs jotai vs Redux @reduxjs/toolkit vs jotai vs react-redux vs recoil vs redux | npm trends

この記事を書いた時点では Recoil 23.5KB で jotai が 2.3KB 確かに小さいです!! (Recoil が思ったより大きかった)

環境
  • jotai 2.0.3
  • React 18.2.0
  • TypeScript: 4.9.3

状態 (state) = atom

jotai では Recoil と同様に state は atom と呼ぶ

import { atom } from 'jotai';
const countAtom = atom<number>(0);

Recoil と違って key の設定が必要ない

atom の使い方 useAtom

useAtom フックで atom と更新関数が取得できる。
React の useState, Recoil の useRecoilState と同じように扱える

import { atom, useAtom } from 'jotai';
const countAtom = atom<number>(0);

const Component: React.FC = () => {
  const [count, setCount] = useAtom(countAtom);
  const handleInclremt = () => setCount((value) => value + 1);
  const handleReset = () => setCount(0);

  return (
    <div>
      <div>Count: {count}</div>
      <button type="button" onClick={handleInclremt}>Increment</button>
      <button type="button" onClick={handleReset}>Reset</button>
    </div>
  );
};

値だけ使いたい時は useAtomValue(), 更新関数だけ使いたい時は useSetAtom() を使える。(Recoil で言う所の useRecoilValue()useSetRecoilState())
Recoil と同様にコンポーネントで直接 useAtom は呼ばないようにして、カスタムフックの中に閉じ込めてインターフェイスを制限するのが良さそう

依存のある atom

Recoil では selector で表現できるものを jotai では atom((get) => {}) で表現できる

import { atom } from "jotai";
const countAtom = atom<number>(0);
const doubleCountAtom = atom<number>((get) => get(countAtom) * 2);

以降の扱い方は通常の atom と同じ

import { atom, useAtom } from 'jotai';
const countAtom = atom<number>(0);
const doubleCountAtom = atom<number>((get) => get(countAtom) * 2);

const Component: FC = () => {
  const [count, setCount] = useAtom(countAtom);
  const [doubleCount, setDoubleCount] = useAtom(doubleCountAtom);
  // setDoubleCount(value) は never なのでエラーになる
  const handleInclremt = () => setCount((value) => value + 1);
  const handleReset = () => setCount(0);

  return (
    <div>
      <div>Count: {count} / Double: {doubleCount}</div>
      <button type="button" onClick={handleInclremt}>Increment</button>
      <button type="button" onClick={handleReset}>Reset</button>
    </div>
  );
};

第一引数が get 関数だけの atom は readonly となり set 関数は never で使えなくなっているが、個人的には Recoil の selector と別物になっている方が間違いなく見通しが良いように感じた

依存元を更新できる atom readWriteAtom

const readOnlyAtom = atom((get) => get(priceAtom) * 2)
const writeOnlyAtom = atom(
  null, // it's a convention to pass `null` for the first argument
  (get, set, update) => {
    // `update` is any single value we receive for updating this atom
    set(priceAtom, get(priceAtom) - update.discount)
  }
)
const readWriteAtom = atom(
  (get) => get(priceAtom) * 2,
  (get, set, newPrice) => {
    set(priceAtom, newPrice / 2)
    // you can set as many atoms as you want at the same time
  }
)

cf. atom — Jotai, primitive and flexible state management for React

jotai では第一引数が get 関数の atom も第二引数を指定することで、set 関数で依存元を更新できる atom が作成できる。逆に第一引数を null にすると別の atom を更新できるだけの atom を作成できる。

readWriteAtom のサンプル

import { atom, useAtom } from "jotai";

const kgAtom = atom<number>(0);
const gramAtom = atom(
  (get) => get(kgAtom) * 1000,
  (_get, set, newValue: number) => {
    return set(kgAtom, newValue / 1000);
  }
);

export const Converter: FC = () => {
  const [kg, setKg] = useAtom(kgAtom);
  const [gram, setGram] = useAtom(gramAtom);
  const handleChangeKg = (evt: React.ChangeEvent<HTMLInputElement>) => {
    setKg(Number(evt.currentTarget.value));
  };
  const handleChangeGram = (evt: React.ChangeEvent<HTMLInputElement>) => {
    setGram(Number(evt.currentTarget.value));
  };

  return (
    <div>
      <label>
        <input type="number" value={kg} onChange={handleChangeKg} />
          kg
       </label><label>
        <input type="number" value={gram} onChange={handleChangeGram}/>
          g
      </label>
   </div>
  );
};

このサンプルでは依存先である gramAtom から依存元の kgAtom の値を更新することができます
カスタム hook で対象の atom を更新するだけのインターフェイスを作れるから個人的に 依存先の atom から更新ができる Read-Write な atom の使い所はちょっと思いつきませんでした…

非同期で更新される atom

jotai では atom の第一関数を非同期関数にすることで非同期で取得できる値の状態を扱える
loadable を使うことで loading 中やエラーの扱いを簡単にすることができます

loadable
If you don't want async atoms to suspend or throw to an error boundary (for example, for finer-grained control of loading and error logic), you can use the loadable util.
It would work the same way for any atom. Simply wrap your atoms with the loadable util. It returns a value with one of three states: loading, hasData and hasError.
cf. Async — Jotai, primitive and flexible state management for React

Pokemon API からデータを取得する例

atoms.ts

import { atom } from "jotai";
import { loadable } from "jotai/utils";

const API = "https://pokeapi.co/api/v2/pokemon/" as const;

export const queryAtom = atom<number | undefined>(undefined);

const asyncAtom = atom(async (get) => {
  const query = get(queryAtom);
  if (!query) {
    return;
  }
  const data = await fetch(`${API}${query}`).then((res) => res.json());

  return {
    name: data.name || "",
    img: data.sprites?.front_default || ""
  };
});
// 非同期な atom を loadable でラップする
export const loadableAtom = loadable(asyncAtom);

コンポーネント

import { useRef, FC } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { loadableAtom, queryAtom } from "./atoms";

const LoadContent: FC = () => {
  const { state, data, error } = useAtomValue(loadableAtom);
  // data, error が Property does not exist on type になってしまう…
  if (state === "hasError") {
    if (error instanceof Error) {
      return (<div>Error: {error.message}</div>);
    }
    return <div>Error</div>;
  }
  if (state === "loading") {
    return <div>Loading...</div>;
  }
  if (!data) {
    return null;
  }
  return (
    <div>
      <img src={data.img} alt={data.name} />
      <label>{data.name}</label>
    </div>
  );
};

const SearchForm: FC = () => {
  const setQuery = useSetAtom(queryAtom);
  const inputRef = useRef<HTMLInputElement>(null);
  const handleChangeQuery = (evt: React.MouseEvent) => {
    if (!inputRef.current || !inputRef.current.value) {
      return;
    }
    const query = inputRef.current.value;
    setQuery(Number(query));
  };

  return (
    <div>
      <label>Pokemon No.</label>
      <input ref={inputRef} type="number" min="1" />
      <button type="button" onClick={handleChangeQuery}>
        Search
      </button>
    </div>
  );
};

export const PokemonSearch: FC = () => {
  return (
    <div>
      <LoadContent />
      <SearchForm />
    </div>
  );
}

フォームに ポケモンのナンバーを入れて検索ボタンを押すと queryAtom が変更され、queryAtom に依存してしている asyncAtom に変更が伝播して再 fetch が実行され検索結果が更新される

jotai だけで useEffect を使わずに非同期処理が扱えるので atom がネストさえ秘匿してしまえば見通しは良さそう。
ただ react-querySWR といった非同期に特化したようなライブラリがあるので使い時は限定されそう…

Sample


📝 TypeScript での Tips

Derived atoms are also type inferred and explicitly typed

const asyncStrAtom = atom(async () => 'foo')
const writeOnlyAtom = atom(null, (_get, set, str: string) => set(fooAtom, str))
const readWriteAtom = atom<string, number>(
  (get) => get(strAtom),
  (_get, set, num) => set(strAtom, String(num))
)

cf. TypeScript — Jotai, primitive and flexible state management for React

TypeScript (TypeScript v4.9.3) で read-write な atom を定義する時に公式にある方法でも型エラーが発生したのでメモ

1. atom<type, type> は set関数が never になり使えない

const gramAtom = atom<number, number>(
  (get) => get(kgAtom) * 1000,
  (_get, set, newValue) => set(kgAtom, newValue / 1000)
);

const Component: FC = () => {
  const [gram, setGram] = useAtom(gramAtom);
  const handleChangeGram = (evt: React.ChangeEvent<HTMLInputElement>) => {
    setGram(Number(evt.currentTarget.value));
    // => This expression is not callable. Type 'never' has no call signatures.
  };
  // … 
};

ドキュメントに書かれている指定方法だが、set 関数の setGram が never 型 (const setGram: never) と推論されてしまい、set関数を使おうとした箇所で This expression is not callable. Type 'never' has no call signatures. のType エラーが発生してしまった

2. atom<type> だと第二引数で Type error になる

const gramAtom = atom<number>(
  (get) => get(kgAtom) * 1000,
  (_get, set, newValue) => set(kgAtom, newValue / 1000)
  // => Expected 1 arguments, but got 2
);

write を指定できる第二引数の指定箇所が Expected 1 arguments, but got 2 になってしまう
また更新時に渡される値が入る newValueunknown 型になる

解決方法 Generics で型指定しない

型指定しないといい感じに推論される… どうして…

const gramAtom = atom(
  (get) => get(kgAtom) * 1000,
  (_get, set, newValue) => set(kgAtom, newValue / 1000)
);

const Component: FC = () => {
  const [gram, setGram] = useAtom(gramAtom);
  // const setGram: SetAtom<[newValue: unknown], void>
  // …
};

これは Type error が回避される。但し newValue が unknown 型として推論される
👇 関数定義の部分に型を指定すればOK

const gramAtom = atom(
  (get) => get(kgAtom) * 1000,
- (_get, set, newValue) => set(kgAtom, newValue / 1000)
+ (_get, set, newValue: number) => set(kgAtom, newValue / 1000)
);

const Component: FC = () => {
  const [gram, setGram] = useAtom(gramAtom);
  // const setGram: SetAtom<[newValue: number], void>
  // …
};

newValue に直接型を指定すれば set 関数もいい感じに形推論してくれるようになりました!
atom だけで色々できるようにした結果、型定義が難しくなってるのかもしれません

所管

ざっと触っただけですが jotai は Recoil と同じように使うことができそうです!
jotai は React の Context を利用したライブラリで Recoil に比べて軽量なのは魅力的だなと思いました。一方で依存のある状態も全て atom として表現するので TypeScript の型がうまく推論されない部分があったり、 atomselector とで別になっている Recoil の方が見通しは良さそうという印象も持ちました。 また、v1 と v2 で API が大きく変更されてたりとするので、現状では個人的な小さなプロジェクトで実験的に使うとかなら良さそうという肌感でした

他にも Provider や localStorage, sessionStorage を利用した state を作成できる utility 機能もあるので使いこなせると出来ることは多そうです! (Provider については調べてたのですが記事が長くなりすぎそうだったので、別記事にしようと思います)

Recoil の記事書こうと思ったまま下書きに放置してたことを思い出した…
おわり つづく!

👇 つづき


基礎から学ぶ React/React Hooks

基礎から学ぶ React/React Hooks

Amazon

[asin:B0BN5BF2SF:detail]