Server Actionsによる無限スクロール
本記事の完成品です。
はじめに
Next.js 13 では、Server Actions という機能が追加されました。これは、サーバーサイドで実行される関数を定義することができる機能です。今回は、この Server Actions を利用して、無限スクロールを実装する方法を紹介します。
事前準備
Server Actions を利用するためには、next.config.jsに以下の設定を追加する必要があります。
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;Jsonplaceholder API
今回は、Jsonplaceholderという API を利用します。これは、テスト用の API で、以下のようなエンドポイントが用意されています。
こちらは、_startと_limitというクエリパラメータを指定することで、取得するデータの開始位置と取得するデータの数を指定することができます。
無限スクロールの実装
まずは、app/page.tsxを以下のように編集します。
import Image from 'next/image';
import LoadMore from '@/components/LoadMore';
import { Card } from '@/components/ui/card';
const PAGE_SIZE = 10;
type PostType = {
userId: number;
id: number;
title: string;
url: string;
thumbnailUrl: string;
};
const getPosts = async (offset: number = 0) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts?_start=${offset}&_limit=${PAGE_SIZE}`);
const json = await res.json();
return json as PostType[] | [];
};
const PostList = async ({ posts }: { posts: PostType[] }) => {
return (
<>
{posts?.map((post: PostType) => (
<Card key={post.id}>
<div className="flex flex-col items-center p-4 gap-4">
<Image src={post.thumbnailUrl} alt={post.title} />
<span className="text-2xl font-bold text-black capitalize">{post.title}</span>
</div>
</Card>
))}
</>
);
};
async function loadMorePost(offset: number = 0) {
'use server';
const post = await getPosts(offset);
const nextOffset = post.length >= PAGE_SIZE ? offset + PAGE_SIZE : null;
return [
// @ts-expect-error async RSC
<PostList offset={offset} posts={post} key={offset} />,
nextOffset,
] as const;
}
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 Server Actions Example
</h1>
<div className="flex flex-col gap-4 items-center">
<LoadMore loadMoreAction={loadMorePost} initialOffset={PAGE_SIZE}>
{/* @ts-expect-error async RSC */}
<PostList posts={initialPosts} />
</LoadMore>
</div>
</main>
);
}コードの説明をします。
-
getPosts関数は、offsetを指定することで、指定した位置からPAGE_SIZE分のデータを取得する関数です。 -
PostListコンポーネントは、postsを受け取って、Cardコンポーネントを表示するコンポーネントです。 -
loadMorePost関数は、次のページの投稿をロードします。この関数は、新たに投稿を取得し、新しいオフセットを計算します。もし新たに取得した投稿の数がページのサイズ(この場合は 20)より少なければ、新しいオフセットは null となります。これは、これ以上投稿がないことを意味します。use serverというコメントを付けることで、Server Actions(サーバーサイドで実行される関数)であることを示しています。- また、
offsetを受け取って、PostListコンポーネントと、次のオフセットを返します。(例えば、offsetが 0 の場合は、PostListコンポーネントと 20 を返します。offsetが 20 の場合は、PostListコンポーネントと 40 を返します。...etc)
-
Homeコンポーネントは、初期のpostsを取得して、LoadMoreコンポーネントを表示するコンポーネントです。
次に、app/components/LoadMore.tsxを以下のように編集します。
'use client';
import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Label } from './ui/label';
import { Switch } from './ui/switch';
type loadMoreAction<T extends string | number = any> = T extends number
? (offset: T) => Promise<readonly [JSX.Element, number | null]>
: T extends string
? (offset: T) => Promise<readonly [JSX.Element, string | null]>
: any;
const LoadMore = <T extends string | number = any>({
children,
initialOffset,
loadMoreAction,
}: PropsWithChildren<{
initialOffset: T;
loadMoreAction: loadMoreAction<T>;
}>) => {
const ref = useRef<HTMLButtonElement>(null);
const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
const [disVisible, setDisVisible] = useState(false);
const currentOffsetRef = useRef<number | string | undefined>(initialOffset);
const [scrollLoad, setScrollLoad] = useState(true);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(
async (abortController?: AbortController) => {
setLoading(true);
// @ts-expect-error Can't yet figure out how to type this
loadMoreAction(currentOffsetRef.current)
.then(([node, next]) => {
if (abortController?.signal.aborted) return;
setLoadMoreNodes((prev) => [...prev, node]);
if (next === null) {
currentOffsetRef.current ??= undefined;
setDisVisible(true);
return;
}
currentOffsetRef.current = next;
})
.catch(() => {})
.finally(() => setLoading(false));
},
[loadMoreAction],
);
useEffect(() => {
const signal = new AbortController();
const element = ref.current;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && element?.disabled === false) {
loadMore(signal);
}
});
if (element && scrollLoad) {
observer.observe(element);
}
return () => {
signal.abort();
if (element) {
observer.unobserve(element);
}
};
}, [loadMore, scrollLoad]);
return (
<>
<div className="fixed container top-4 z-50 flex justify-end">
<Label htmlFor="scrollLoad" className="cursor-pointer">
<Card className="w-max flex p-4 gap-4 items-center m-2">
<Switch
id="scrollLoad"
onCheckedChange={() => setScrollLoad((prev) => !prev)}
checked={scrollLoad}
></Switch>
<span>Fetch on scroll</span>
</Card>
</Label>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 pt-12 relative">
{children}
{loadMoreNodes}
</div>
{!disVisible && (
<Button variant="outline" size="lg" ref={ref} onClick={() => loadMore()}>
{loading ? 'Loading...' : 'Load More'}
</Button>
)}
</>
);
};
export default LoadMore;コードの説明をします。
loadMoreActionは、offsetを受け取って、Promiseを返す関数です。Promiseは、JSX.Elementと、次のオフセットを返します。もし、次のオフセットがない場合は、nullを返します。LoadMoreコンポーネントは、childrenを表示し、loadMoreActionを実行するボタンを表示するコンポーネントです。loadMore関数は、loadMoreActionを実行します。loadMoreActionは、Promiseを返すので、Promiseが完了するまで、loadingをtrueにします。Promiseが完了したら、loadingをfalseにします。Promiseが完了したら、loadMoreActionの結果をloadMoreNodesに追加します。もし、次のオフセットがない場合は、disVisibleをtrueにします。scrollLoadがtrueの場合は、IntersectionObserverを使って、loadMore関数を実行します。scrollLoadがfalseの場合は、loadMore関数は実行されません。scrollLoadを切り替えるためのSwitchコンポーネントを表示します。
まとめ
以上になります。
一般的に無限スクロールはSWRやReact Queryを使って CC で実装すると思いますが、SC でも実装できるのではないかと思い、Server Actionsで実装してみました。
参考になれば幸いです。