はじめに
はじめまして、未経験/独学からフロントエンドエンジニアとしての就職を目指しているYuuhaと申します。
就職活動をするにあたって、これまで勉強してきたことや、分かったことを備忘録的にまとめていきたいと思います。
今回は、既存の写真投稿アプリのクローンアプリを制作したときに学習した内容です。
本記事では、無限スクロールの実装を例にNext.jsで私が最初につまずいたポイントのServer Actionsについて初学者でもなるべく分かりやすいように解説していきます。(本記事では、実装のポイント解説がメインとなりますので、実際の実装については、参考文献をご覧ください)
制作したもの
参考文献
1.Server Actionsについて
Next.js(App Router)では、クライアントコンポーネント側からサーバーサイドで行う処理を呼び出すことは基本的には出来ません。そこで、用意されているのがServer Actionsです。
クライアントコンポーネントにおける"use client"と同様に、"use server"ディレクティブを指定することで、Server Actionsとして利用できます。
Server Actionsはサーバー/クライアントサイドどちらからも呼び出すことが可能です。
したがって、クライアント側の操作に応じてサーバーサイドでの処理を実行することが可能になります。ただし、どこからでもアクセスできるため、セキュリティ上の観点で危険性が高い処理はServer Actionsでは行わないといった注意が必要です。
2.実装の概要
Server Actioins データ取得用の関数の定義
今回実装したいのは、スクロールで画面下部に到達したら、これまでのデータの続きを一定件数取得してくるというものです。
まずは、クライアントコンポーネントで呼び出すための、データ取得用のloadFunc関数(Server Actions)をサーバーコンポーネント側に定義します。getPhotoList関数は{start}番目のデータからスタートして、{size}件だけデータを取得するという関数です。データの取得はMicroCMSから行っています。
async function getPhotoList(start: number, size: number) {
const listData = await client.getList<Photo>({
endpoint: "photos",
queries: { offset: start, limit: size },
});
const resultData = listData.contents;
return resultData;
}
// Server Actionsで追加の写真データを取得する関数を定義
async function loadFunc(offset: number) {
"use server";
const pageSize = 20;
const PhotoList = await getPhotoList(offset, pageSize);
const nextOffset = PhotoList.length >= pageSize ? offset + pageSize : null;
return [
PhotoList.map((result) => (
<PhotoCard photoData={result} key={result.id} id={result.id} />
)),
nextOffset,
] as const;
}
loadFunc関数では、現在のオフセット(現在のデータ取得件数/データを何件目まで取得しているか)を引数として受け取り、そのオフセットを始点として一定の件数(今回は20件)のデータを取得します。
取得したデータを、表示するための子コンポーネント(PhotoCard)に渡し、それを配列にしたものを返すという処理をしています。
また、オフセットはデータ取得の度に更新しなければいけません。そこで、次のオフセットとなる値nextOffsetも定義して、戻り値として指定してあげます。内容としては、これまでに取得したデータサイズがpageSize(20件)以上なら、現在のオフセットにpageSizeを足すというだけです。nextOffsetの右辺が理解できない場合は、3項演算子で調べてみて下さい。
コールバック関数としてクライアントコンポーネントに引き渡す
上記で定義したloadFunc関数をクライアントコンポーネントにコールバック関数として渡してあげます。こうすることで、クライアントサイドで関数の実行タイミングを決定しつつ、処理はサーバーサイドで行うということが実現できます。ここで、重要なのが、関数名の後に()をつけないことです。()をつけてしまうと、関数の戻り値を引数として渡すことを意味するので、関数名だけを記載することで、関数そのものを渡してあげることができます。
export default async function Home() {
const initialPhotos = await getPhotoList(0, 20);
return (
<LoadMore
loadMoreAction={loadFunc}
initialOffset={pageSize}
initialPost={initialPhotos}
>
<PhotoList photos={initialPhotos} />
</LoadMore>
);
}
データ取得の関数実行タイミングの制御
データのロードタイミングの監視にはIntersection Observerを使用します。これは、指定したDOM要素が画面内に表示された際に、Intersection Observer内に定義した関数を実行してくれるというものです。
useEffect(() => {
const abortController = new AbortController();
// ここで指定したDOM要素が画面に表示されると、関数が実行される
const element = ref.current;
// IntersectionObserver を使用して、実行したい関数を定義
const observer = new IntersectionObserver(([entry]) => {
// DOM要素が画面内に表示されているか確認
if (entry.isIntersecting) {
// データを取得する(Server Actions)関数を呼び出す
loadMore(abortController);
}
});
// DOM要素が存在する場合、その要素の監視を開始させる
if (element) {
observer.observe(element);
}
return () => {
// ボタン要素の監視を終了
if (element) {
observer.unobserve(element);
}
};
}, [loadFunc]);
取得したデータの更新
上記では、画面下部までスクロールすると、データ取得の関数(loadMore関数)を実行する処理を書きました。なので、そのloadMore関数の中身を記述していきます。まず必要なStateを定義しておきます。
State | 役割 |
---|---|
loadMoreNodes | 取得したデータのJSX(コンポーネント)配列を保持するState |
loading | データのロード状況を表し、スピナーの表示を制御するためのState |
allDataLoaded | 全データが取得された場合に、スピナーを非表示にするためのState |
現在のオフセットについては、オフセットの値が変更されてもDOMへの影響がないので、Refを使ってcurrentOffsetRefとして記録しておきます。あとは、コールバック関数を利用するので、useCallbackの中でloadFuncを実行し、戻り値のJSX配列、現在のオフセットを取得します。戻り値はloadFunc().then([nodes, next])のnodesとnextがそれにあたります。戻り値の値をStateにセットすることで、変更がDOMに反映され、新しいデータが表示されるという仕組みです。
const [loadMoreNodes, setLoadMoreNodes] = useState<JSX.Element[]>([]);
const [loading, setLoading] = useState(false);
const [allDataLoaded, setAllDataLoaded] = useState(false);
// 現在のオフセット
const currentOffsetRef = useRef<number | undefined>(initialOffset);
// 新しいデータを取得する関数
const loadMore = useCallback(
async (abortController?: AbortController) => {
setLoading(true);
setTimeout(async () => {
// 重複データの取得を防ぐためのチェック
if (currentOffsetRef.current === undefined) {
setLoading(false);
return;
}
loadFunc(currentOffsetRef.current)
.then(([node, next]) => {
// リクエストが中断された場合は早期リターン
if (abortController?.signal.aborted) return;
// 全てのデータを取得したかどうかのチェック
if (node.length < 20) {
setAllDataLoaded(true);
}
// 新しいデータを追加する
setLoadMoreNodes((prev) => [...prev, ...node]);
//ここでAllModalDataの配列にpushする
if (next === null) {
currentOffsetRef.current = undefined;
return;
}
currentOffsetRef.current = next;
})
.catch((e) => {
console.log(e);
})
.finally(() => setLoading(false));
}, 800);
},
[loadFunc]
);
return (
<>
<ul>
{children}
{loadMoreNodes}
</ul>
{!allDataLoaded && (
<button className={styles.triggerButton} ref={ref}>
{loading && <Spinner size={'lg'} />}
<Spinner />
</button>
)}
</>
);
まとめ
以上、Server Actionsを利用した無限スクロールの実装についての解説でした。
今回は写真投稿アプリ制作を通して、無限スクロールの実装について解説しました。ただ、長くなってしまうので省略しましたが、この他にも検索機能や写真の投稿機能、ジャンル別での写真一覧の取得といった機能も実装しております。学習目的に作ったアプリのため、お気に入り機能など、一部ハリボテですが、気になった方はご覧いただけると幸いです。