Server Actions を使用した無限スクロール
はじめに 🚩
このアイデアは以下の YouTube のライブ配信を視聴中、
その中で Server Actions を使用して JSX を返すことが可能 という情報を知り(55分辺り)、この情報に触発され、Server Actions を活用して無限スクロールの実装ができないか、試してみることにしました。(もう三ヶ月も前なんですね...)
本記事で説明する実装に関する動作は以下の X の埋め込みからご覧いただけます。
実装例 📝
JsonPlaceholder でのデータ呼び出し
まずデータの取得が必要となるので、この例では、公開されている fake API の JsonPlaceholder を使用してデータを取得します。
以下は、JsonPlaceholderから投稿データを取得するための関数の例です。
const PAGE_SIZE = 10;
type PostType = {
userId: number;
id: number;
title: string;
body: string;
};
const getPosts = async (offset: number = 0) => {
const json = await fetch(
`https://jsonplaceholder.typicode.com/posts?_start=${offset}&_limit=${PAGE_SIZE}`
).then((res) => res.json());
return json as PostType[] | [];
};
この関数は、指定されたオフセットから10件の投稿データを取得します。PAGE_SIZE
を使用して、一度に取得するデータの件数を制御しています。このようにして、無限スクロールの際に次のデータセットを取得するための基盤を作成します。
Server Actions の実装
以下は、Server Actionsを使用して、更に投稿データをロードするための関数の例です。
async function loadMorePost(offset: number = 0) {
'use server';
const posts = await getPosts(offset);
const nextOffset = posts.length >= PAGE_SIZE ? offset + PAGE_SIZE : null;
return [
posts.map((post: PostType) => <PostCard key={post.id} post={post} />),
nextOffset,
] as const;
}
この関数 loadMorePost は、指定されたオフセットから次のデータセットを取得し、それをPostCardコンポーネントとしてマッピングします。また、次のオフセットも計算して返しています。これにより、無限スクロールの際に次のデータセットを取得するための情報も提供されます。
page.tsx での実装
次に、メインページの実装を行います。このページでは、初めに投稿データを取得し、それを表示するとともに、さらにデータをロードするための機能を提供します。この機能の核心部分として、LoadMore コンポーネントが使用されています。このコンポーネントの詳細については、次のセクションで説明します。
export default async function Home() {
const initialPosts = await getPosts(0);
return (
<main className='flex min-h-screen flex-col container mb-8 mt-32'>
<h1 className='text-2xl md:text-4xl font-bold mb-8 text-black text-center'>
Infinite Scroll with Server Actions
</h1>
<div className='flex flex-col gap-4 items-center'>
<LoadMore loadMoreAction={loadMorePost} initialOffset={PAGE_SIZE}>
<PostList posts={initialPosts} />
</LoadMore>
</div>
</main>
);
}
PostList, PostCard コンポーネントの実装
投稿のリストと各投稿を表示するためのコンポーネントを実装します。ここでは、shadcn/ui を使用してコンポーネントを構築しています。
const PostList = async ({ posts }: { posts: PostType[] }) => {
return (
<>
{posts?.map((post: PostType) => (
<PostCard key={post.id} post={post} />
))}
</>
);
};
const PostCard = ({ post }: { post: PostType }) => {
return (
<Card key={post.id}>
<CardHeader>
<CardTitle className='truncate'>{post.title}</CardTitle>
<CardDescription>{post.body}</CardDescription>
</CardHeader>
</Card>
);
};
LoadMore コンポーネントについて
このコンポーネントは、指定されたオフセットから新しいデータを取得し、それを表示するための機能を提供します。また、データの取得中にはスピナーを表示して、ユーザーにデータのロード中であることを知らせます。
LoadMore.tsx 全コード
'use client';
import {
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useToast } from './ui/use-toast';
import { Spinner } from './Spinner';
type LoadMoreAction = (
offset: number
) => Promise<readonly [JSX.Element[], number | null]>;
const LoadMore = ({
children,
initialOffset,
loadMoreAction,
}: PropsWithChildren<{
initialOffset: number;
loadMoreAction: LoadMoreAction;
}>) => {
const ref = useRef<HTMLButtonElement>(null);
const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
const [loading, setLoading] = useState(false);
const [allDataLoaded, setAllDataLoaded] = useState(false);
// 現在のオフセット
const currentOffsetRef = useRef<number | undefined>(initialOffset);
const { toast } = useToast();
// 新しいデータを取得する関数
const loadMore = useCallback(
async (abortController?: AbortController) => {
setLoading(true);
setTimeout(async () => {
// 重複データの取得を防ぐためのチェック
if (currentOffsetRef.current === undefined) {
setLoading(false);
return;
}
loadMoreAction(currentOffsetRef.current)
.then(([node, next]) => {
// リクエストが中断された場合は早期リターン
if (abortController?.signal.aborted) return;
// 全てのデータを取得したかどうかのチェック
if (node.length < 10) {
setAllDataLoaded(true);
}
// 新しいデータを追加する
setLoadMoreNodes((prev) => [...prev, ...node]);
if (next === null) {
currentOffsetRef.current = undefined;
return;
}
currentOffsetRef.current = next;
})
.catch((e) => {
console.log(e);
toast({
variant: 'destructive',
title: 'エラーが発生しました🥲',
});
})
.finally(() => setLoading(false));
}, 800);
},
[loadMoreAction, toast]
);
useEffect(() => {
// オブザーバーを使用して、スピナーが表示されたときに新しいデータを取得する
const abortController = new AbortController();
const element = ref.current;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && element?.disabled === false) {
loadMore(abortController);
}
});
if (element) {
observer.observe(element);
}
return () => {
abortController.abort();
if (element) {
observer.unobserve(element);
}
};
}, [loadMore]);
return (
<>
<ul className='grid grid-cols-2 gap-4 p-4'>
{children}
{loadMoreNodes}
</ul>
{!allDataLoaded && (
<button className='w-full flex justify-center items-center' ref={ref}>
{loading && <Spinner size={'lg'} className='self-center' />}
</button>
)}
</>
);
};
export default LoadMore;
型定義
type LoadMoreAction = (
offset: number
) => Promise<readonly [JSX.Element[], number | null]>;
loadMoreAction は、指定されたオフセットから新しいデータを取得する関数の型を定義しています。この関数は、取得したデータのJSX要素の配列と次のオフセット(またはデータがない場合はnull)を返すPromiseを返す必要があります。
状態の管理
const ref = useRef<HTMLButtonElement>(null);
const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
const [loading, setLoading] = useState(false);
const currentOffsetRef = useRef<number | undefined>(initialOffset);
変数名 | 説明 |
---|---|
ref | スクロールの最下部に位置するボタン要素への参照を保持します。この参照を使用して、ユーザーがページの最下部に到達したときに新しいデータの取得をトリガーするためのIntersection Observerを設定します。 |
loadMoreNodes | 新しく取得したデータを表示するためのJSX要素の配列を保持する状態です。新しいデータが取得されるたびに、この配列に新しい要素が追加されます。 |
loading | 現在データの取得中かどうかを示す状態です。データの取得中はtrue、取得が完了したらfalseになります。この状態を使用して、データ取得中にスピナーを表示するかどうかを制御します。 |
currentOffsetRef | 次に取得するデータのオフセットを保持する参照です。データの取得が完了するたびに、このオフセットは更新されます。 |
新しいデータの取得
const loadMore = useCallback(
async (abortController?: AbortController) => {
setLoading(true);
setTimeout(async () => {
if (currentOffsetRef.current === undefined) {
setLoading(false);
return;
}
// 指定されたオフセットから新しいデータを取得
loadMoreAction(currentOffsetRef.current)
.then(([node, next]) => {
// リクエストが中断された場合、処理を中止
if (abortController?.signal.aborted) return;
// 新しい JSX を追加
setLoadMoreNodes((prev) => [...prev, ...node]);
// 次のオフセットが null の場合、これ以上のデータ取得は不要と判断
if (next === null) {
currentOffsetRef.current = undefined;
return;
}
// 次のオフセットを設定
currentOffsetRef.current = next;
})
.catch((e) => {
console.log(e);
toast({
variant: 'destructive',
title: 'エラーが発生しました🥲',
});
})
.finally(() => setLoading(false));
}, 800);
},
[loadMoreAction, toast]
);
loadMore 関数は、新しいデータを取得するための主要な関数です。指定されたオフセットからデータを取得し、それをloadMoreNodes状態に追加します。また、データの取得中は loading 状態を true に設定して、スピナーを表示します。
スクロールの監視
useEffect(() => {
const abortController = new AbortController();
// スクロールの最下部に位置するボタン要素への参照
const element = ref.current;
// IntersectionObserver を使用して、ボタン要素が画面内に表示されたかを監視
const observer = new IntersectionObserver(([entry]) => {
// ボタン要素が画面内に表示され、かつボタンが無効化されていない場合
if (entry.isIntersecting && element?.disabled === false) {
// 新しいデータを取得する関数を呼び出し
loadMore(abortController);
}
});
// ボタン要素が存在する場合、その要素を監視対象として追加
if (element) {
observer.observe(element);
}
return () => {
// 進行中のリクエストを中止
abortController.abort();
// ボタン要素の監視を終了
if (element) {
observer.unobserve(element);
}
};
}, [loadMore]);
useEffect フックを使用して、スクロールの監視を行います。ユーザーがページの最下部に到達した際に、loadMore 関数を呼び出して新しいデータを取得します。
レンダリング
return (
<>
<ul className='grid grid-cols-2 gap-4 p-4'>
{children}
{loadMoreNodes}
</ul>
<button className='w-full flex justify-center items-center' ref={ref}>
{loading && <Spinner size={'lg'} className='self-center' />}
</button>
</>
);
最後に、取得したデータとスピナーを表示します。データの取得中は、スピナーが表示され、データが取得されたらそのデータが表示されます。
全データ取得後のスピナーの停止
現在の実装では、全てのデータが取得された後も、画面の最下部に到達するたびにスピナーが表示されてしまいます。以下のコード追加で、全てのデータが取得された場合にはスピナーを表示しないように修正します。
'use client';
type loadMoreAction = (
offset: number
) => Promise<readonly [JSX.Element[], number | null]>;
const LoadMore = ({
children,
initialOffset,
loadMoreAction,
}: PropsWithChildren<{
initialOffset: number;
loadMoreAction: loadMoreAction;
}>) => {
const ref = useRef<HTMLButtonElement>(null);
const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
const [loading, setLoading] = useState(false);
+ const [allDataLoaded, setAllDataLoaded] = useState(false);
// 現在のオフセット
const currentOffsetRef = useRef<number | undefined>(initialOffset);
const { toast } = useToast();
// 新しいデータを取得する関数
const loadMore = useCallback(
async (abortController?: AbortController) => {
setLoading(true);
setTimeout(async () => {
// 重複データの取得を防ぐためのチェック
if (currentOffsetRef.current === undefined) {
setLoading(false);
return;
}
loadMoreAction(currentOffsetRef.current)
.then(([node, next]) => {
// リクエストが中断された場合は早期リターン
if (abortController?.signal.aborted) return;
+ if (node.length < PAGE_SIZE) {
+ setAllDataLoaded(true);
+ }
// 新しいデータを追加する
setLoadMoreNodes((prev) => [...prev, ...node]);
if (next === null) {
currentOffsetRef.current = undefined;
return;
}
currentOffsetRef.current = next;
})
.catch((e) => {
console.log(e);
toast({
variant: 'destructive',
title: 'エラーが発生しました🥲',
});
})
.finally(() => setLoading(false));
}, 800);
},
[loadMoreAction, toast]
);
useEffect(() => {
// オブザーバーを使用して、スピナーが表示されたときに新しいデータを取得する
const abortController = new AbortController();
const element = ref.current;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && element?.disabled === false) {
loadMore(abortController);
}
});
if (element) {
observer.observe(element);
}
return () => {
abortController.abort();
if (element) {
observer.unobserve(element);
}
};
}, [loadMore]);
return (
<>
<ul className='grid grid-cols-2 gap-4 p-4'>
{children}
{loadMoreNodes}
</ul>
+ {!allDataLoaded && (
<button className='w-full flex justify-center items-center' ref={ref}>
{loading && <Spinner size={'lg'} className='self-center' />}
</button>
+ )}
</>
);
};
export default LoadMore;
まとめ 📌
この記事では、Next.js の Server Actions を利用して、無限スクロールの機能を実装する方法について解説しました。
筆者としてはこの方法を提案しましたが、より良い方法や改善点、指摘、別のアプローチがあれば、ぜひフィードバックお願いします🙇♂️
以上です!
Discussion