🌀
「Recoil selector活用パターン 無限スクロール編」のJotai版
を見ました。これめっちゃ面白い。再帰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