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
で実装してみました。
参考になれば幸いです。