この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。
0.前提条件
X(旧Twitter)のクローンサイトの制作過程で投稿機能を実装した内容をご紹介したいと思います。
フロントエンド:React(JavaScript) 19.1.0
バックエンド:Ruby on Rails(Ruby) 7.0.0 APIモード
インフラ:Docker
PC;Mac Book Air M2チップ
1.実装概要
- 前へ、次へのボタンで1ページ分戻る or 進む
- 最初、最後のボタンで最初のページ or 最後のページを表示
- 最大5ページ分のページ数を表示して、投稿ページが変わる度に表示しているページ数部分を変更する
2.実装内容
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import { TweetTabs } from "../molecules/TweetTabs";
import { RecommendationComponent } from "./RecommendationComponent";
import { FollowComponent } from "./FollowComponent";
import { TweetInput } from "./TweetInput";
import { PostModal } from "./PostModal";
import { axiosInstance } from "../../utils/HandleAxios";
import { HandleError } from "../../utils/HandleError";
const TweetBox = styled.div`
width: 35%;
color: white;
`;
export const TweetView = ({ showPostModal, closePostModalHandler }) => {
// タブの切り替えを管理するstate変数
const [activeTab, setActiveTab] = useState("recommendation");
const [tweets, setTweets] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [totalTweets, setTotalTweets] = useState(0);
const query = new URLSearchParams({ limit: 10, offset: 0 });
// 引数のtabにrecommendation、もしくはfollowを入れることでstate関数によりタブを切り替える
const handleTabClick = (tab) => {
setActiveTab(tab);
};
// 初期画面でおすすめタブのデータを取得する
useEffect(() => {
let ignore = false;
const describeTweet = async () => {
try {
const response = await axiosInstance.get(`/tweets?${query.toString()}`);
if (!ignore) {
console.log(response.data);
setTweets(response.data.data.tweets);
setTotalTweets(response.data.data.count);
setIsLoading(false);
}
} catch (error) {
HandleError(error);
}
};
describeTweet();
return () => {
ignore = true;
};
}, []);
return (
<TweetBox>
<TweetTabs activeTab={activeTab} onTabClick={handleTabClick} />
<TweetInput />
{activeTab === "recommendation" && (
<RecommendationComponent // このコンポーネント内でページネーション実装
tweets={tweets}
isLoading={isLoading}
setIsLoading={setIsLoading}
setTweets={setTweets}
totalTweets={totalTweets}
/>
)}
{activeTab === "follow" && <FollowComponent />}
<PostModal show={showPostModal} close={closePostModalHandler}></PostModal>
</TweetBox>
);
};
ここでは親コンポーネントとして投稿一覧画面を含めたメインページを実装しています。
今回のページネーション実装はTweetInputコンポーネントのRecommendationComponentコンポーネントで実装した内容になります。
余談ですが、コンポーネント名に◯◯Componentと命名したのは失敗でした...後で修正したいと思います。
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import styled from "styled-components";
import { ImageIcon } from "../atoms/ImageIcon";
import relativeTime from "dayjs/plugin/relativeTime";
import dayjs, { locale, extend } from "dayjs";
import "dayjs/locale/ja";
import { HandleError } from "../../utils/HandleError";
import toast from "react-hot-toast";
import { HandleOffset } from "../../utils/HandleOffset";
import { HandlePagination } from "../../utils/HandlePagination";
// 投稿日の表示を現在の日付から「何日前」で表示する
locale("ja");
extend(relativeTime);
const TweetBox = styled.div`
width: 100%;
display: flex;
margin-top: 10px;
`;
const IconBox = styled.div`
margin-left: 10px;
`;
const NameAndTimeBox = styled.div`
display: flex;
margin-bottom: 0px;
`;
const NameTag = styled.p`
margin: 0px;
font-weight: bold;
`;
const TimeTag = styled.p`
margin: 0px 0px 0px 10px;
`;
const ContentBox = styled.div`
width: 100%;
margin: 0px 10px 10px 10px;
word-break: break-all;
`;
const ContentTag = styled.p`
margin-top: 0px;
`;
const PaginationBox = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const FirstPageButton = styled.button`
background-color: white;
border-radius: 5px;
`;
const LastPageButton = styled.button`
background-color: white;
border-radius: 5px;
`;
const PrevButton = styled.button`
background-color: white;
border-radius: 5px;
`;
const NextButton = styled.button`
background-color: white;
border-radius: 5px;
`;
export const RecommendationComponent = ({
tweets,
isLoading,
setIsLoading,
setTweets,
totalTweets,
}) => {
const [currentOffset, setCurrentOffset] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
// newOffset, newPage, maxPagesはstate変数のままで管理するとレンダリングできないので、ローカル変数を使用
let newOffset = currentOffset;
let newPage = currentPage;
let maxPages = Math.ceil(totalTweets / 10);
// 現在のページから一つ前のページへ遷移する
const describePrevTweet = async () => {
try {
newOffset = currentOffset - 10;
newPage = newOffset / 10 + 1;
if (newOffset >= 0) {
HandleOffset({
setIsLoading,
currentOffset,
setCurrentOffset,
newOffset,
setTweets,
});
setCurrentPage(newPage);
} else {
toast("最初のページです。");
return;
}
} catch (error) {
HandleError(error);
}
};
// 現在のページから次のページへ遷移する
const describeNextTweet = async () => {
try {
newOffset = currentOffset + 10;
newPage = newOffset / 10 + 1;
if (totalTweets > newOffset) {
HandleOffset({
setIsLoading,
currentOffset,
setCurrentOffset,
newOffset,
setTweets,
});
setCurrentPage(newPage);
} else {
toast("最後のページです。");
return;
}
} catch (error) {
HandleError(error);
}
};
// ページ数のボタンをクリックしたら指定のページへ遷移する
const describeDesignatedTweet = async (i) => {
// 押されたボタンのページ数を取得
newPage = i;
// 指定したページの投稿情報を取得する
try {
newOffset = (newPage - 1) * 10;
HandleOffset({
setIsLoading,
currentOffset,
setCurrentOffset,
newOffset,
setTweets,
});
setCurrentPage(newPage);
} catch (error) {
HandleError(error);
}
};
// ページネーション用のページ数を表示させる。表示するページ数は5ページ分とする
const renderPageNumbers = () => {
const pageNumbers = [];
// 総ページ数が5ページ以上の場合は表示方法を分ける
if (5 <= maxPages) {
// 現在のページが1 or 2ページ目の場合
if (currentPage === 1 || currentPage === 2) {
for (let i = 1; i <= maxPages && pageNumbers.length <= 4; i++) {
HandlePagination({
i,
describeDesignatedTweet,
pageNumbers,
currentPage,
});
}
// 現在のページが最終ページの場合
} else if (currentPage === maxPages) {
for (
let i = currentPage - 4;
i <= maxPages && pageNumbers.length <= 4;
i++
) {
HandlePagination({
i,
describeDesignatedTweet,
pageNumbers,
currentPage,
});
}
// 現在のページが最終ページの1つ前の場合
} else if (currentPage === maxPages - 1) {
for (
let i = currentPage - 3;
i <= maxPages && pageNumbers.length <= 4;
i++
) {
HandlePagination({
i,
describeDesignatedTweet,
pageNumbers,
currentPage,
});
}
// 現在のページの前後に2ページずつ存在する場合
} else {
for (
let i = currentPage - 2;
i <= maxPages && pageNumbers.length <= 4;
i++
) {
HandlePagination({
i,
describeDesignatedTweet,
pageNumbers,
currentPage,
});
}
}
// 総ページ数が4ページ以下の場合は1ページ目から順番に表示するのみ。表示形式は指定無し
} else if (maxPages < 5) {
for (let i = 1; i <= maxPages && pageNumbers.length <= 3; i++) {
HandlePagination({
i,
describeDesignatedTweet,
pageNumbers,
currentPage,
});
}
}
return pageNumbers;
};
return (
<>
{isLoading && <h2>Now Loading...</h2>}
{!!tweets &&
tweets.map((tweet) => (
<TweetBox key={tweet.id}>
<IconBox>
<ImageIcon />
</IconBox>
<ContentBox>
<NameAndTimeBox>
{/* <p>{tweet.user.name}</p> */}
<NameTag>名無しの権兵衛</NameTag>{" "}
<TimeTag>{dayjs(tweet.created_at).fromNow()}</TimeTag>
{/* プロフィール実装までの仮 */}
</NameAndTimeBox>
<ContentTag>{tweet.content}</ContentTag>
{tweet.image_urls.length > 0 && (
<img src={tweet.image_urls} width="400px" height="400px" />
)}
</ContentBox>
</TweetBox>
))}
<PaginationBox>
{/* 無限レンダリングが発生するので、onClick部分はアロー関数で定義 */}
<FirstPageButton onClick={() => describeDesignatedTweet(1)}>
最初
</FirstPageButton>
<PrevButton onClick={describePrevTweet}>前へ</PrevButton>
<div>{renderPageNumbers()}</div>
<NextButton onClick={describeNextTweet}>次へ</NextButton>
{/* 無限レンダリングが発生するので、onClick部分はアロー関数で定義 */}
<LastPageButton onClick={() => describeDesignatedTweet(maxPages)}>
最後
</LastPageButton>
</PaginationBox>
</>
);
};
ここでは取得した投稿データを表示するためのコンポーネントを実装しています。
describePrevTweet及びdescribeNextTweet関数では、投稿データを取得・表示するにはページの遷移先に応じてoffsetを変えてバックエンド側へ送信します。ページを1ページ分進める or 戻る場合はoffsetを±10してクエリパラメーターに入れてデータリクエストすることで指定のページデータを取得します。
describeDesignatedTweet関数では ページ数指定や最初 or 最後のページを指定する場合はページ数を使ってoffsetの値を算出し、バックエンド側へリクエストします。
※バックエンド側の処理などについては後述しています。
renderPageNumbersはページネーションのページ数をボタンとして表示するための関数です。表示するページ番号は最大5個として、取得したデータ総数から計算したページ数が5ページを超えるかどうかで条件を設けました。現在のページが総ページの中でどこに位置するかでさらに条件分岐を起き、表示するデータが変わる度にページ番号を併せて表示します。
ページ総数が5ページよりも少ない場合は単純に左側から1...と表示します。
ローカル変数としてoffsetをnewOffset変数、currentPageをnewPage変数、総ページ数をmaxPages変数として定義しているのは、state変数のまま管理してしまうとレンダリングされる際に値がstate変数に反映されるのが1テンポ遅いので、想定した通りに動作しないからです。
describeDesignatedTweet及びdescribeDesignatedTweetは引数を渡すので、末尾に()を使った記述になっており、これは即実行を意味します。
この場合、無限レンダリングされてしまうので、アロー関数を使った記述にすることでクリックした時のみ実行されるようにしています。
import React from "react";
import styled from "styled-components";
const CurrentPageNumber = styled.span`
font-weight: bold;
`;
const DesignatedButton = styled.button`
background-color: white;
border-radius: 5px;
`;
export const HandlePagination = ({
i,
describeDesignatedTweet,
pageNumbers,
currentPage,
}) => {
pageNumbers.push(
currentPage === i ? (
<CurrentPageNumber key={i} id="current-page">
{i}
</CurrentPageNumber>
) : (
// 無限レンダリングが発生するので、onClick部分はアロー関数で定義
<DesignatedButton key={i} onClick={() => describeDesignatedTweet(i)}>
{i}
</DesignatedButton>
)
);
};
ここは共通処理として切り出したページ数表示の配列を作成する処理です。
呼び出し元ではfor文の中になり、変数iが現在のページであればonClick関数は持たせずにページ数を表示するのみで、それ以外の場合はクリックした時に指定ページの投稿データを取得・表示するようにpropsで受け取ったdescribeDesignatedTweetを実行します。
describeDesignatedTweet()
と記述すると無限レンダリングされてしまうので、代わりにアロー関数で記述しています。
関数名の後に()と実行を意味する記述があることが原因です。
import React from "react";
import { axiosInstance } from "./HandleAxios";
export const HandleOffset = async ({
setIsLoading,
setCurrentOffset,
newOffset,
setTweets,
}) => {
const query = new URLSearchParams({ limit: 10, offset: newOffset });
setIsLoading(true);
setCurrentOffset(newOffset);
const response = await axiosInstance.get(`/tweets?${query.toString()}`);
setTweets(response.data.data.tweets);
setIsLoading(false);
};
ここは共通処理として切り出したAPIからデータを取得する処理となります。
バックエンド側にHTTPメソッドでデータ取得する際にクエリパラメーターとしてlimitとoffsetを渡すことで必要なデータ数だけ(limit)取得して、表示ページが変われば取得するデータをズラす(offset)ことで必要なデータを取得します。
取得する度に投稿データ(取得した)を管理しているsetTweets関数で内容を更新・再レンダリングします。
また、データ取得は非同期処理なので(async await)、僅かな時間ですがロード中の画面を出すためにsetIsLoading関数で管理しています。
module Api
module V1
class TweetsController < ApplicationController
def index
limit_params = params[:limit]&.to_i
offset_params = params[:offset]&.to_i
@tweets = Tweet.all.order(created_at: 'DESC').limit(limit_params).offset(offset_params)
render json: { stats: 'SUCCESS', message: 'Have gotten all tweets', data: { tweets: @tweets, count: Tweet.all.count } }, include: [:user]
end
end
end
end
こちらはバックエンド側(Rails)のデータ取得処理です。
クエリパラメーターで受け取ったlimitとoffsetを使ってデータベースからデータを取得して、JSON形式でフロントエンド側に投稿データとデータ総数を返却しています。
limitで取得するデータを制限し、offsetで降順データの頭からどれだけズラしてデータを取得するかを決めています。
データ総数はページ数を計算する際に必要なので、投稿データと一緒に返却することにしました。
3.参考資料
Pagination for rails api and react api
【意外とハマる??】Reactで、無限ループに気をつけましょう
Ruby: Railsのpage/per、SQLのoffset/limitみたいな処理をRubyのArray配列に適応したい