1. はじめに
Next.js × Laravel でブログ投稿サイトを作成していました。
その際にページネーションの実装で詰まったので突破した解決策を記しておこうと思います。
目次
1.はじめに
2.使用環境
3.背景
4.ページネーション実装前の状況
5.ページネーション実装
6.ページネーション完成時のコード
7.最後に
2. 使用環境
- MacBook Air : M2チップ / 16 GB
- Next.js : 14.1.0
- React : 18.2.0
- PHP : 8.2.14
- Laravel Framework : 10.39.0
- My SQL : 8.0.32
- PHP My Admin : 5.2.1
- Docker :
Client: Cloud integration: v1.0.35+desktop.5 Version: 24.0.7 API version: 1.43 Go version: go1.20.10 Git commit: afdd53b Built: Thu Oct 26 09:04:20 2023 OS/Arch: darwin/arm64 Context: desktop-linux Server: Docker Desktop 4.26.0 (130397) Engine: Version: 24.0.7 API version: 1.43 (minimum version 1.12) Go version: go1.20.10 Git commit: 311b9ff Built: Thu Oct 26 09:08:15 2023 OS/Arch: linux/arm64 Experimental: false containerd: Version: 1.6.25 GitCommit: d8f198a4ed8892c764191ef7b3b06d8a2eeb5c7f runc: Version: 1.1.10 GitCommit: v1.1.10-0-g18a0cb0 docker-init: Version: 0.19.0 GitCommit: de40ad0
3. 背景
記事投稿サイトのクローンを、任意のフロントエンドとバックエンドの言語を選択して実装する RealWorld に挑戦しました。
私は今回、フロントエンドに Next.js、バックエンドに Laravel を選択し、Next.js ↔ Laravel API ↔ MySQL という流れで CRUD 機能を実装しました。
その中で、投稿一覧ページのページネーションを実装する過程において、以下のような内容を学べたので、まとめたいと思います。
- Laravel 側の Controller での記述方法
- Next.js 側で全投稿を複数ページに分割するために、Laravel API からのレスポンスデータをどのように活用したらいいか
- Next.js 側のダイナミックルーティングを利用したページネーションの画面遷移
4. ページネーション実装前の状況
バックエンド - Laravel
Laravel Sail でプロジェクトを作成し、Laravel Breezeを API オプションでインストールしています。
sail artisan breeze:install api
Laravel 側では画面を描写せず、Next.js 側からのリクエストに応じてデータベース操作をすることに専念します。
今回は認証機能は実装していませんが、今後実装する予定なので、Laravel Breeze を導入するとデフォルトで作成される Users テーブルも活用しています。
記事を新規作成する際は仮に User_idを 1 として登録するようにしました。
ルーター
routes/api.php
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ArticleController;
Route::prefix('articles')
->name('articles.')
->controller(ArticleController::class)
->group(function () {
Route::get('/', 'index')->name('index');
Route::get('/{id}', 'get')->name('get');
Route::post('/', 'create')->name('create');
Route::put('/{id}', 'update')->name('update');
Route::delete('/{id}', 'delete')->name('delete');
});
コントローラー
App\Http\Controllers\ArticleController.php
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use App\Models\Tag;
use App\Models\ArticleTag;
use Illuminate\Http\Exceptions\HttpResponseException;
use App\Http\Requests\StoreApiArticleRequest;
use App\Http\Requests\UpdateApiArticleRequest;
class ArticleController extends Controller
{
/*
* 投稿一覧の取得
*/
public function index()
{
$articles = Article::with('tags')->orderBy('updated_at', 'desc')->get();
return response()->json($articles, 200);
}
/*
* 個別投稿の取得
*/
public function get(string $id)
{
$article = Article::find($id);
if ($article === null) {
$res = response()->json(
[
'errors' => '投稿が見つかりませんでした',
],
404
);
throw new HttpResponseException($res);
}
$article_tags = ArticleTag::where('article_id', $article->id)->get();
$tag_names = [];
if ($article_tags !== null) {
foreach ($article_tags as $article_tag) {
$tags = Tag::find($article_tag->tag_id);
$tag_names[] = $tags->name;
}
}
$data = [
'id' => $article->id,
'title' => $article->title,
'about' => $article->about,
'content' => $article->content,
'tagList' => $tag_names
];
return response()->json($data, 200);
}
/*
* 新規投稿
*/
public function create(StoreApiArticleRequest $request)
{
$article = Article::create([
'title' => $request['article']['title'],
'about' => $request['article']['description'],
'content' => $request['article']['body'],
'user_id' => 1
]);
$tags = $request['article']['tagList'];
$tag_names = [];
foreach ($tags as $tag) {
if ($tag !== null) {
$tag_data = Tag::firstOrCreate(['name' => $tag]);
$tag_names[] = $tag_data->name;
ArticleTag::create([
'article_id' => $article->id,
'tag_id' => $tag_data->id
]);
}
}
$data = [
"id" => $article->id,
"title" => $article->title,
"description" => $article->about,
"body" => $article->content,
"tagList" => $tag_names
];
return response()->json($data, 201);
}
/*
* 投稿の更新
*/
public function update(UpdateApiArticleRequest $request, string $id)
{
$article = Article::find($id);
if ($article === null) {
$res = response()->json(
[
'errors' => '投稿が見つかりませんでした',
],
404
);
throw new HttpResponseException($res);
}
$data = [];
if (array_key_exists('title', $request['article'])) {
$title = $request['article']['title'];
if (array_key_exists('description', $request['article'])) {
$about = $request['article']['description'];
} else {
$about = $article->about;
}
if (array_key_exists('body', $request['article'])) {
$content = $request['article']['body'];
} else {
$content = $article->content;
}
$new_article = Article::create([
'title' => $title,
'about' => $about,
'content' => $content,
'user_id' => 1
]);
$article->delete();
$data = [
"id" => $new_article->id,
"title" => $new_article->title,
"description" => $new_article->about,
"body" => $new_article->content
];
} else {
if (array_key_exists('description', $request['article'])) {
$about = $request['article']['description'];
} else {
$about = $article->about;
}
if (array_key_exists('body', $request['article'])) {
$content = $request['article']['body'];
} else {
$content = $article->content;
}
$new_article = Article::find($id);
$new_article->title = $article->title;
$new_article->about = $about;
$new_article->content = $content;
$new_article->save();
$article_tags = ArticleTag::where(['article_id' => $id])->get();
$tag_names = [];
foreach ($article_tags as $article_tag) {
if ($article_tag !== null) {
$tag_data = Tag::where('tag_id', $article_tag->tag_id);
$tag_names[] = $tag_data->name;
}
}
$data = [
"id" => $new_article->id,
"title" => $new_article->title,
"description" => $new_article->about,
"body" => $new_article->content,
"tagList" => $tag_names
];
}
return response()->json($data, 201);
}
/*
* 投稿の削除
*/
public function delete(string $id)
{
$article = Article::find($id);
if ($article === null) {
$res = response()->json(
[
'errors' => '投稿が見つかりませんでした',
],
404
);
throw new HttpResponseException($res);
}
$article_tags = ArticleTag::where(['article_id' => $id]);
$article_tags_data = $article_tags->get();
if ($article_tags_data !== null) {
foreach ($article_tags_data as $article_tag_data) {
$tag_id = $article_tag_data->tag_id;
$existing_article_tags = ArticleTag::where(['tag_id' => $tag_id])->get();
if (count($existing_article_tags) === 1) {
Tag::where('id', $tag_id)->delete();
}
}
$article_tags->delete();
}
$article->delete();
$data = [
'message' => '削除されました'
];
return response()->json($data, 200);
}
}
マイグレーション
テーブルは以下の 4 つです。
- Users テーブル
ユーザーID(id)、名前(name)、メール(email)、メール認証日時(email_verified_at)、パスワード(password)、トークン、タイムスタンプを保存。 - Articles テーブル
記事のID(id)、タイトル(title)、概要(about)、内容(content)、タイムスタンプを保存。 - Tags テーブル
タグのID(id)、タグ名(name)、タイムスタンプを保存。 - ArticleTag テーブル
記事(article_id)に紐づくタグ(tag_id)を保存する中間テーブル。
database\migrations\ <日時>_create_users_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};
database\migrations\ <日時>_create_articles_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('title', 100);
$table->string('about', 255);
$table->text('content');
$table->foreignId('user_id')->constrained('users');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('articles');
}
};
database\migrations\ <日時>_create_tags_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name', 20);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tags');
}
};
database\migrations\ <日時>_create_article_tag_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('article_tag', function (Blueprint $table) {
$table->foreignId('article_id')->constrained('articles')->cascadeOnDelete();;
$table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('article_tag');
}
};
モデル
App\Http\Models\Users.php(デフォルトのまま)
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function article()
{
return $this->hasMany(Article::class);
}
}
App\Http\Models\Article.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
use HasFactory;
/*
* 複数挿入可能な属性を指定
*/
protected $fillable = [
'title',
'about',
'content',
'user_id'
];
/*
* 記事に紐づくユーザーを取得
*/
public function user()
{
return $this->belongsTo(User::class);
}
/*
* 記事に紐づくタグを取得
*/
public function tags()
{
return $this->belongsToMany(Tag::class);
}
}
App\Http\Models\Tag.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Tag extends Model
{
use HasFactory;
protected $fillable = [
'name'
];
public function article()
{
return $this->belongsToMany(Article::class);
}
}
App\Http\Models\ArticleTag.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ArticleTag extends Model
{
use HasFactory;
protected $table = 'article_tag';
public $timestamps = false;
protected $fillable = [
'article_id',
'tag_id'
];
}
ファクトリー・シーダー
ダミーデータを登録する Factory と Seeder も作成・実行していますが、ここでは割愛します。
フロントエンド - Next.js
先に Laravel API 実装し、Postman で CRUD 機能が問題なく機能していることを確認してから、フロント側を実装し始めました。
RealWorld で各ページの HTML が準備されており、それをを元に手直しをしながら実装していきます。
参考:RealWorld テンプレート
公開されているデモサイトはこちら
※残念ながら、サインインしてログイン後の仕様を確認することはできないようです。そのため、ログイン後の投稿機能などは、コードを見た感じでの想像による実装となります。
Next.js プロジェクトは、以下の GitHub リポジトリをクローンして作成しました。
GitHub - laravel/breeze-next
すでに Laravel API と通信するための設定がされています。
app\lib\axios.js
import Axios from 'axios'
const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
withCredentials: true,
withXSRFToken: true,
})
export default axios
ここで使用されている変数 NEXT_PUBLIC_BACKEND_URL
は、.env.loval
ファイルで定義しています。
.env.loval
NEXT_PUBLIC_BACKEND_URL=http://localhost
この設定は、localhost:8000
がデフォルトです。
ただし、Laravel Sail を使用している場合は上記のように localhost
となります。
この axios コンポーネントを活用して、リクエストを送信していきます。
app\page\page.js
'use client'
import Link from 'next/link'
import axios from '@/lib/axios'
import React, { useEffect, useState } from 'react'
export default function Page() {
const [articles, setArticles] = useState([])
useEffect(() => {
const getArticles = async () => {
try {
const response = await axios.get('api/articles')
console.log(response.data)
setArticles(response.data)
} catch (err) {
console.log(err)
}
}
getArticles()
}, [])
return (
<>
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
<div className="feed-toggle">
<ul className="nav nav-pills outline-active">
<li className="nav-item">
<a className="nav-link" href="">
Your Feed
</a>
</li>
<li className="nav-item">
<a className="nav-link active" href="">
Global Feed
</a>
</li>
</ul>
</div>
<div>
{articles.map(article => {
return (
<>
<div className="article-preview">
<div className="article-meta">
<a href="/profile/eric-simons">
<img src="http://i.imgur.com/Qr71crq.jpg" />
</a>
<div className="info">
<a href="/profile/eric-simons" className="author">
Eric Simons
</a>
<span className="date">January 20th</span>
</div>
<button className="btn btn-outline-primary btn-sm pull-xs-right">
<i className="ion-heart" /> 29
</button>
</div>
<Link
href={`/article/${article.id}`}
className="preview-link">
<h1>{article.title}</h1>
<p>{article.about}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tags.map(tag => {
return (
<li
key={`${article.id}.${tag.id}`}
className="tag-default tag-pill tag-outline">
{tag.name}
</li>
)
})}
</ul>
</Link>
</div>
</>
)
})}
</div>
{/* ページネーション部分 */}
<ul className="pagination">
<li className="page-item active">
<a className="page-link" href="">
1
</a>
</li>
<li className="page-item">
<a className="page-link" href="">
2
</a>
</li>
</ul>
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<div className="tag-list">
<a href="" className="tag-pill tag-default">
programming
</a>
<a href="" className="tag-pill tag-default">
javascript
</a>
<a href="" className="tag-pill tag-default">
emberjs
</a>
<a href="" className="tag-pill tag-default">
angularjs
</a>
<a href="" className="tag-pill tag-default">
react
</a>
<a href="" className="tag-pill tag-default">
mean
</a>
<a href="" className="tag-pill tag-default">
node
</a>
<a href="" className="tag-pill tag-default">
rails
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
上記のコードでページネーションを差し込むのはこの部分です。
<ul className="pagination">
<li className="page-item active">
<a className="page-link" href="">
1
</a>
</li>
<li className="page-item">
<a className="page-link" href="">
2
</a>
</li>
</ul>
ここではまだ RealWorld のテンプレート HTML をそのまま貼り付けただけで、見かけ上ボタンが置いてあるだけで機能していません。
ちなみに、RealWorld のテンプレートは Bootstrap が使われている他、CSS も提示されているため、CDN として Head 要素で読み込んでいます。
そのうえで、現状の画面表示は以下のような状態です。
また、リンクを貼るだけでなく、現状では Laravel側の ArticleController の index メソッドで取得してきた記事全件が 1 ページにずらっと表示されている状態なので、1 ページに表示する件数を決め、ページを分割する必要があります。
今回は RealWorld の 記事投稿サイト作成の中でページネーションの実装を解説していますが、ロジックは他の Next.js × Laravel プロダクトでも問題なく機能すると思います。
5. ページネーション実装
ここからの手順は以下の通りです。
- Laravel 側:ArticleController の index メソッド内で、get メソッドでデータを取得している部分を paginate メソッドに変更。
- Next.js 側:paginate メソッドにより取得したデータがレスポンスされるので、そのデータの形式に合わせ、変数への格納方法などを変更。
- Next.js 側:Pagination コンポーネントを作成し、props として受け取ったデータをもとにリンクを作成。
- 分割されたページのうち何ページ目にアクセスするかをクエリパラメータでリクエストし、その値を params で取得できるようにする
5-1. Laravel 側:paginate メソッドに変更
Laravel では、ページネーションのためのメソッドが用意されています。
get メソッドの代わりにこの paginate メソッドを使用することで、引数に渡した件数毎のページを表示するのに便利な形式でデータを渡してくれます。
Laravel 公式ドキュメント:ページネーション
ということで、ArticleController の index メソッド を修正します。
get メソッドを使用していた部分を、paginate メソッドに書き換えたものが以下です。
Laravel - App\Http\Controllers\ArticleController.php
{
// 記事全件取得 ( 20 件ずつのページネーション対応)
public function index()
{
$articles = Article::with('tags')->orderBy('updated_at', 'desc')->paginate(20);
return response()->json($articles, 200);
}
【上記コードの説明】
-
Article
モデル(articles
テーブルのデータ)と、それに紐づくtags
テーブルのデータを全件取得しています。 -
orderBy
メソッドで、update_at
(更新日時)をdesc
(降順)に並び替えることで、データが最近更新された順に並ぶようにしています。 - そのデータに対して、
paginate(20)
を実行することによって、20 件ずつのページネーションを作成するための形式でデータを渡してくれるようになります。
5-2. Next.js 側:ページネーション形式のデータの取り扱い
ここで、Laravel の ArticlesController で get メソッドを使って取得したデータと、今回 paginate メソッドに変更した際のデータの違いについて見てみましょう。
console.log(response.data) の結果の一部です。
※ Faker と Seeder で挿入したダミーデータが入っています。
get メソッドの場合
{
"id": 2,
"title": "いきな扉とびついた。「そいでいましたカムパネルラの人が、ちら紫むらさきにわらか敷物しきを。",
"about": "そうそのまって行くひろったね。どんどんどうしをかけました。「まあそびにかこしここへ来たり、きれいな皮かわかにくり返かえし、カムパネルラといったのでしたが、まるで鉄砲弾。",
"content": "快ゆかいろの、と思いないんだ。変へんあわたりがとうものが、わたしかけようにあるような顔をまん中には」鳥捕とり口笛くちがどこから顔を引っ込こんどんなはなしずかにいってぼおっしはすっかりや、かお魚もいつ」「早いかんでした。鳥捕とりとりごとに石でこんばしょうは涼すずしく胸むねをお持もっと胸むねあがりましょうしをかぶって、家庭教師かてんじをだしました。",
"user_id": 9,
"created_at": "2024-01-25T04:16:19.000000Z",
"updated_at": "2024-01-25T04:16:19.000000Z",
"tags": [
{
"id": 42,
"name": "dignissimos",
"created_at": "2024-01-25T04:16:20.000000Z",
"updated_at": "2024-01-25T04:16:20.000000Z",
"pivot": {
"article_id": 2,
"tag_id": 42
}
}
]
},
{
"id": 3,
"title": "旗はたを高くなら何かまっ黒な、お辞儀じぎをすると思うとして島しましたが、続つづってひろってね、そのすぐ北を指さしな気持きも切れず膝ひざもあげました。ほんとは。",
"about": "ほど熟練じゅう、こんどんなはなをさがどこへ顔をした。「まあ、ある野原に大学士だい」「そうか」女の子をジョバンニのお宮みやこっちをしまいました。「ではっぱいですか」「ぼくが行くのでした。ジョバンニは川がもう涼すずしいようにも、それからかのかたなけむりは、けれどもが頭を出し抜ぬけだものはじめたその小さなおしの、かわるきれいな野原。",
"content": "きから、あらわれるだろうとした。「今晩こんばしをとったようにそこかの草の露つゆが太陽たい何です。そしているんだん早くなり、わからもうその鶴つるした。「蠍さそりは眼めをそら、もうずに冷つめたりは、二人ふたり席せきゆをふるうすっと近くのように星のから外をさがしに下でたまりそっちに祈いのにぎらっしょうど両手りょうてをあけました。いや、もう、しずかにゆっくりかが、急いそいでした。",
"user_id": 17,
"created_at": "2024-01-25T04:16:19.000000Z",
"updated_at": "2024-01-25T04:16:19.000000Z",
"tags": [
{
"id": 35,
"name": "quia",
"created_at": "2024-01-25T04:16:20.000000Z",
"updated_at": "2024-01-25T04:16:20.000000Z",
"pivot": {
"article_id": 3,
"tag_id": 35
}
},
{
"id": 78,
"name": "quod",
"created_at": "2024-01-25T04:16:20.000000Z",
"updated_at": "2024-01-25T04:16:20.000000Z",
"pivot": {
"article_id": 3,
"tag_id": 78
}
}
]
},
// 以下略
paginate メソッドの場合
{
"current_page": 1,
"data": [
{
"id": 59,
"title": "字かつじをしずかな岩いわが、ここでとまりがなら」カムパネルラもいるらしいんできいんでなくないよく言いったのですか。立派りっぱりす。",
"about": "ャツが入り乱みだなのほんとうを買って、少し顔いろに集あつくしい人たちはすぐに銀河ぎんががぼうで、だまって、その黒服くろふくろふくをなら近くで鳴りませんで行くのでした。そこらのお母さんかくひろげ、耳をする。",
"content": "のようにしっかさん、いつはなしてね、紀元前きげんか決けっしょう」カムパネルラなんとう。大きなり風がいとジョバンニの方の包つつみは、黒い星座早見せいしゃたべていました大人おとはね上がり、いつかまえでなく声をあげてしかたいしょうきょうはちょうばいけないとがって、浮うか」と言いっていました。",
"user_id": 15,
"created_at": "2024-01-25T04:16:19.000000Z",
"updated_at": "2024-01-25T04:16:19.000000Z",
"tags": [
{
"id": 25,
"name": "amet",
"created_at": "2024-01-25T04:16:20.000000Z",
"updated_at": "2024-01-25T04:16:20.000000Z",
"pivot": {
"article_id": 59,
"tag_id": 25
}
},
{
"id": 11,
"name": "neque",
"created_at": "2024-01-25T04:16:19.000000Z",
"updated_at": "2024-01-25T04:16:19.000000Z",
"pivot": {
"article_id": 59,
"tag_id": 11
}
}
]
},
{
"id": 55,
"title": "の音ねや草花のに気がしに行こうの席せきに、もうの天の川の水の湧わくよ」男の子をジョバンニとすれているのでした。とこにいったのでしょんぴょん跳とんでした空のす。",
"about": "なれていました。「いるよ」ジョバンニは、つかカムパネルラが、ほんしゅはやいたようなもの。あのプラのときからちらの青じろい環わとが胸むねにもついていました。まあおとともうこんどん流ながらんです。その羽。",
"content": "フーて言いいました黒い測候所そっこっちょうがあるよ」男の子は、ぼんやり見えるのですか」ジョバンニはまるでもと、台のともると、もうどさっそう考えました。その譜ふを聞いて、何かだねえさまだそうその電燈でんと紅べには、ばらくしゃがあっちをおどっかくに近づく一あしあとは指ゆびわを刻きざまのお宮みやで二つに来たっているのが見えなくプラタナスの葉ははこち咲さいてあったのでした。",
"user_id": 26,
"created_at": "2024-01-25T04:16:19.000000Z",
"updated_at": "2024-01-25T04:16:19.000000Z",
"tags": [
{
"id": 38,
"name": "sint",
"created_at": "2024-01-25T04:16:20.000000Z",
"updated_at": "2024-01-25T04:16:20.000000Z",
"pivot": {
"article_id": 55,
"tag_id": 38
}
}
]
}
],
"first_page_url": "http://localhost/api/articles?page=1",
"from": 1,
"last_page": 6,
"last_page_url": "http://localhost/api/articles?page=6",
"links": [
{
"url": null,
"label": "« Previous",
"active": false
},
{
"url": "http://localhost/api/articles?page=1",
"label": "1",
"active": true
},
{
"url": "http://localhost/api/articles?page=2",
"label": "2",
"active": false
},
// 中略
{
"url": "http://localhost/api/articles?page=6",
"label": "6",
"active": false
},
{
"url": "http://localhost/api/articles?page=2",
"label": "Next »",
"active": false
}
],
"next_page_url": "http://localhost/api/articles?page=2",
"path": "http://localhost/api/articles",
"per_page": 20,
"prev_page_url": null,
"to": 20,
"total": 102
}
お気づきでしょうか?
paginate
メソッドで取得したデータの方は、テーブルに挿入されているデータ以外のものが含まれています。
get
メソッドで取得できるデータは、paginate
メソッドではdata
プロパティに格納されています。
それに加えて、先頭に格納されているcurrent_page
や、後ろにはfirst_page_url
以降、ページネーションに活用できるデータが含まれています。
また、data
プロパティに含まれるデータ数は、paginate
メソッドの引数に渡した数(今回であれば 20 )になっています。
これらを、Next.js 側で処理していきます。
state に格納する
Next.js - app\page\page.js
'use client'
import axios from '@/lib/axios'
import React, { useEffect, useState } from 'react'
// 略
export default function Page() {
const [pageData, setPageData] = useState()
const [articles, setArticles] = useState([])
useEffect(() => {
const getArticles = async () => {
try {
const response = await axios.get('api/articles')
setPageData(response.data)
setArticles(response.data.data)
} catch (err) {
console.log(err)
}
}
getArticles()
}, [])
// 略
【上記コードの説明】
- 取得したデータを格納する pageData と、その中から記事のデータを取り出して格納する articles の 2 つの state を useState で定義しています。
- pageData のデータは主にページネーションに使用します。
- articles のデータは画面に表示します。
- useEffect は第二引数を空配列
[]
とすることで、画面が最初に読み込まれた時にのみ実行されます。ここでデータを取得してきて、pageData、articles の更新関数を呼び出し、それぞれにデータを格納します。 - paginate メソッドのデータを console.log(response.data) で確認した際に data プロパティに格納されていた部分が記事のデータなので、
respomse.data.data
をsetArticles
に渡しています。
画面への表示
Laravel の ArticleController で get メソッドを使用していた時と同じ記述で問題ありません。
データを格納していた state 名が同じなので、そのまま articles.map() メソッドでデータの数だけ HTML を出力します。
ただし、get メソッドの場合はデータを全件取得しているので表示される数もデータの数だけありましたが、paginate メソッドの場合、今回であれば 20 を引数に渡しているので、取得した 20 件分のデータが出力されることになります。
以下が該当箇所です。
app\page\page.js
{articles.map(article => {
return (
<>
<div className="article-preview">
<div className="article-meta">
<a href="/profile/eric-simons">
<img src="http://i.imgur.com/Qr71crq.jpg" />
</a>
<div className="info">
<a href="/profile/eric-simons" className="author">
Eric Simons
</a>
<span className="date">January 20th</span>
</div>
<button className="btn btn-outline-primary btn-sm pull-xs-right">
<i className="ion-heart" /> 29
</button>
</div>
<Link
href={`/article/${article.id}`}
className="preview-link">
<h1>{article.title}</h1>
<p>{article.about}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tags.map(tag => {
return (
<li
key={`${article.id}.${tag.id}`}
className="tag-default tag-pill tag-outline">
{tag.name}
</li>
)
})}
</ul>
</Link>
</div>
</>
)
})}
5-3. Pagination コンポーネントの作成
components\Pagination.js
'use client'
import Link from 'next/link'
export default function Pagination({ pageData }) {
return pageData ? (
<>
{[...Array(pageData.last_page).keys()].map(page => {
const pageNumber = page + 1
return pageData.current_page === pageNumber ? (
<li key={pageNumber} className="page-item active">
<Link className="page-link" href={`page?page=${pageNumber}`}>
{pageNumber}
</Link>
</li>
) : (
<li key={pageNumber} className="page-item">
<Link className="page-link" href={`page?page=${pageNumber}`}>
{pageNumber}
</Link>
</li>
)
})}
</>
) : null
}
【上記コードの説明】
-
app\page\page.js
で取得したpageData
をprops
の分割代入で受け取っています。 -
pageData ?
の部分は、三項演算子になっています。pageData
が truthy な値であれば、その後の( )
内のコードを返し、falsy な値の場合は、:
右側のnull
を返します。データがあった時だけ HTML が出力されるということです。 -
Array(pageData.last_page)
の部分は、props
で受け取ったpageData
のlast_page
プロパティの値、つまりページネーションのページ数をArray
に渡すことで、ページ数分の配列を生成しています。 -
[...Array(pageData.last_page).keys()]
とすることで、ページ数の配列のキーを取り出し、スプレッド構文...
で配列に格納しています。次のmap
メソッドでページ数分の連番を使いたいからです。 -
map(page => {
以降で、上で生成した連番をpage
変数として使用し、ページ数だけループを回します。 -
const pageNumber = page + 1
で、 0 から始まっていた連番を 1 始まりにしています。 -
return
で、JSX を返しています。三項演算子を用いて、現在のページと同じ pageNumber の場合は active クラスを付与して色がつくようにしています。
- 繰り返し処理には一意の
key
属性を付与しないとエラーになるので、pageNumber
を使用しています。 -
Link
タグのhref
属性で{`page?page=${pageNumber}`}
としてリンクを作成しています。?
以降をクエリパラメータと呼び、page=2
などのようにページ番号が HTTP リクエストと一緒に渡されるようになります。
- クエリパラメータでページ番号を渡すことでそのページの情報が表示されるように、page.js 側も対応する必要があります。(現時点では
pageData
を見て分かるように"current_page": 1
の情報しか取得できていないため、1ページ目しか表示されません。)
呼び出し側のコード
app\page\page.js
// 略
import React, { useEffect, useState } from 'react'
import Pagination from '@/components/Pagination'
const [pageData, setPageData] = useState()
// 中略
<ul className="pagination">
<Pagination pageData={pageData} />
</ul>
【上記コードの説明】
-
Pagination
コンポーネントをimport
します。 -
ul
要素の中でPagination
コンポーネントを呼び出し、props
にpageData
を渡しています。
5-4. クエリパラメータによる動的レンダリング
href={`page?page=${pageNumber}`}
としてクエリパラメータとしてページ数の値を付与したリンクを作成したので、それに対応してクエリパラメータの値によって該当のページの情報を出力できるようにします。
クエリパラメータを取得するには、useSearchParams
を使用します。
app\page\page.js
// 略
import { useSearchParams } from 'next/navigation'
// 略
const searchParams = useSearchParams()
const page = searchParams.get('page')
useEffect(() => {
const getArticles = async () => {
try {
const response = await axios.get(`api/articles?page=${page}`)
setPageData(response.data)
setArticles(response.data.data)
} catch (err) {
console.log(err)
}
}
getArticles()
}, [page])
// 略
【上記コードの説明】
-
useSearchParams
をimport
します。 -
useSearchParams()
の戻り値を変数searchParams
に格納しています。 -
searchParams.get('page')
とすることで、クエリパラメータのうちpage
の値を取得することができるので、戻り値を変数page
に格納します。これを利用し、該当のページの情報を取得します。 -
getArticles
メソッドの中の URL をapi/articles?page=${page}
に修正します。こうすることで、該当のページのデータを取得することができます。 -
useEffect
の第二引数に[page]
を渡しています。これは、page state が変更されるたびにuseEffect
内の処理が実行されることを表します。これを入れておかないと、データを取得しに行かないため再レンダリングされません。
以上で完成です!
6. ページネーション完成時のコード
app\page\page.js
'use client'
import Link from 'next/link'
import axios from '@/lib/axios'
import React, { useEffect, useState } from 'react'
import Pagination from '@/components/Pagination'
import { useSearchParams } from 'next/navigation'
export default function Page() {
const [pageData, setPageData] = useState()
const [articles, setArticles] = useState([])
const searchParams = useSearchParams()
const page = searchParams.get('page')
useEffect(() => {
const getArticles = async () => {
try {
const response = await axios.get(`api/articles?page=${page}`)
console.log(response.data)
setPageData(response.data)
setArticles(response.data.data)
} catch (err) {
console.log(err)
}
}
getArticles()
}, [page])
return (
<>
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
<div className="feed-toggle">
<ul className="nav nav-pills outline-active">
<li className="nav-item">
<a className="nav-link" href="">
Your Feed
</a>
</li>
<li className="nav-item">
<a className="nav-link active" href="">
Global Feed
</a>
</li>
</ul>
</div>
<div>
{articles.map(article => {
return (
<>
<div className="article-preview">
<div className="article-meta">
<a href="/profile/eric-simons">
<img src="http://i.imgur.com/Qr71crq.jpg" />
</a>
<div className="info">
<a href="/profile/eric-simons" className="author">
Eric Simons
</a>
<span className="date">January 20th</span>
</div>
<button className="btn btn-outline-primary btn-sm pull-xs-right">
<i className="ion-heart" /> 29
</button>
</div>
<Link
href={`/article/${article.id}`}
className="preview-link">
<h1>{article.title}</h1>
<p>{article.about}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tags.map(tag => {
return (
<li
key={`${article.id}.${tag.id}`}
className="tag-default tag-pill tag-outline">
{tag.name}
</li>
)
})}
</ul>
</Link>
</div>
</>
)
})}
</div>
<ul className="pagination">
<Pagination pageData={pageData} />
</ul>
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<div className="tag-list">
<a href="" className="tag-pill tag-default">
programming
</a>
<a href="" className="tag-pill tag-default">
javascript
</a>
<a href="" className="tag-pill tag-default">
emberjs
</a>
<a href="" className="tag-pill tag-default">
angularjs
</a>
<a href="" className="tag-pill tag-default">
react
</a>
<a href="" className="tag-pill tag-default">
mean
</a>
<a href="" className="tag-pill tag-default">
node
</a>
<a href="" className="tag-pill tag-default">
rails
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
components\Pagination.js
'use client'
import Link from 'next/link'
export default function Pagination({ pageData }) {
return pageData ? (
<>
{[...Array(pageData.last_page).keys()].map(page => {
const pageNumber = page + 1
return (
<li key={pageNumber} className="page-item">
<Link className="page-link" href={`page?page=${pageNumber}`}>
{pageNumber}
</Link>
</li>
)
})}
</>
) : null
}
7. 最後に
いかがでしたでしょうか。
今回は RealWorld 作成時のコードを例にしているため、ページネーションに関係のない部分も多くあります。
関係のない部分に関しては気にせず、ページネーションの部分のみ参考にしていただけたらいいと思います。
このページネーションの実装方法について調べるのに非常に時間を要したので、同じような状況の方のお役に立てれば幸いです。