🌀

「Recoil selector活用パターン 無限スクロール編」のJotai版

2023/01/22に公開

https://zenn.dev/uhyo/articles/recoil-selector-infinite-scroll

を見ました。これめっちゃ面白い。再帰selectorかっこいい、ほれました。Loadableの使い方も素敵。

Jotai版を作ってみたくなりますよね、はい。作りました。

ほとんどコードの中身を理解せずに移植して、あとからコード読みました。ちなみに、移植で大変だったのは、型の付け方が微妙に違うことです。それ以外はほぼ単純な変換。ここまで互換性があったとは。

出来上がったもの

コード

Recoil版からそのまま使えるものはimportしてます。

import { Suspense, useEffect, useRef } from "react";
import { Atom, atom, useAtomValue, useSetAtom } from "jotai";
import { atomFamily, loadable } from "jotai/utils";
import {
  QueryInput,
  client,
  convertRawResultToPokemon,
  query,
  pageSize,
  PokemonListState
} from "./AppRecoil";

const formattedPokemonListQueryFamily = atomFamily(
  (input: QueryInput) =>
    atom(async () => {
      const result = await client.query(query, input).toPromise();
      if (result.error) {
        throw result.error;
      }
      if (result.data === undefined) {
        throw new Error("No data");
      }
      return convertRawResultToPokemon(result.data);
    }),
  (a, b) => a.offset === b.offset && a.limit === b.limit
);

const totalItemsAtom = atom(pageSize);

const loadNextPageAtom = atom(null, (_get, set) => {
  set(totalItemsAtom, (count) => count + pageSize);
});

const pokemonListRecFamily: (param: {
  requestedItems: number;
  offset: number;
}) => Atom<PokemonListState> = atomFamily(
  ({ requestedItems, offset }) =>
    atom((get) => {
      const limit = Math.min(requestedItems - offset, pageSize);
      const pokemons = get(
        formattedPokemonListQueryFamily({
          limit,
          offset
        })
      );
      if (pokemons.length < limit) {
        return {
          pokemons,
          mightHaveMore: false
        };
      }
      if (requestedItems === offset + limit) {
        return {
          pokemons,
          mightHaveMore: true
        };
      }
      const rest = get(
        loadable(
          pokemonListRecFamily({
            requestedItems,
            offset: offset + limit
          })
        )
      );
      switch (rest.state) {
        case "hasError": {
          throw rest.error;
        }
        case "loading": {
          return {
            pokemons,
            mightHaveMore: true
          };
        }
        case "hasData": {
          return {
            pokemons: [...pokemons, ...rest.data.pokemons],
            mightHaveMore: rest.data.mightHaveMore
          };
        }
      }
    }),
  (a, b) => a.requestedItems === b.requestedItems && a.offset === b.offset
);

const pokemonListAtom = atom((get) =>
  get(
    pokemonListRecFamily({
      requestedItems: get(totalItemsAtom),
      offset: 0
    })
  )
);

const Loading = () => {
  const observedRef = useRef<HTMLParagraphElement | null>(null);
  const loadNextPage = useSetAtom(loadNextPageAtom);
  useEffect(() => {
    if (observedRef.current === null) {
      return undefined;
    }
    let lastTriggerTime = 0;
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            if (lastTriggerTime + 1000 <= Date.now()) {
              lastTriggerTime = Date.now();
              loadNextPage();
            }
          }
        }
      },
      {
        rootMargin: "0px 0px 100px 0px"
      }
    );
    observer.observe(observedRef.current);
    return () => {
      observer.disconnect();
    };
  }, [loadNextPage]);
  return <p ref={observedRef}>Loading...</p>;
};

const PokemonList = () => {
  const { pokemons, mightHaveMore } = useAtomValue(pokemonListAtom);
  return (
    <>
      <dl>
        {pokemons.map((pokemon) => (
          <div key={pokemon.id}>
            <dt lang="ja">{pokemon.ja}</dt>
            <dd>
              {pokemon.en} <span>#{pokemon.id}</span>
            </dd>
          </div>
        ))}
      </dl>
      {mightHaveMore && <Loading />}
    </>
  );
};

function App() {
  return (
    <div className="App">
      <h1>An infinite-scrolling list of Pokemons</h1>
      <Suspense fallback={null}>
        <PokemonList />
      </Suspense>
    </div>
  );
}

export default App;

メモ

  • キャッシュのことを考えると、limitはMath.minをせずにpageSizeのままの方がいいんじゃないかと思ったり
  • Loading...になった後にどうやってリトライが走っているのか、理解できていない
  • atomFamily (selectorFamily)のキャッシュを消すのが大変そう、少なくともJotaiでは

Discussion