WebLog

LaravelとNext.jsでpaginationを実装する

2023/01/07 20:34

前提

記事(Post)を投稿できるような SNS サイトを考える。 フロントは Next.js、バックエンドは Laravel で実装されている。

記事(Post)に対してページネーションを実装したい、つまり 10 件とかの塊でデータを分けて、ページごとにそれを表示する感じのやりとりを API を介して実装したい。

:::details User モデル

app/Models/User.php
1<?php
2
3namespace App\Models;
4
5// use Illuminate\Contracts\Auth\MustVerifyEmail;
6use Illuminate\Database\Eloquent\Factories\HasFactory;
7use Illuminate\Foundation\Auth\User as Authenticatable;
8use Illuminate\Notifications\Notifiable;
9use Laravel\Sanctum\HasApiTokens;
10
11class User extends Authenticatable
12{
13 use HasApiTokens, HasFactory, Notifiable;
14
15 /**
16 * The attributes that are mass assignable.
17 *
18 * @var array<int, string>
19 */
20 protected $fillable = [
21 'name',
22 'email',
23 'password',
24 ];
25
26 /**
27 * The attributes that should be hidden for serialization.
28 *
29 * @var array<int, string>
30 */
31 protected $hidden = [
32 'password',
33 'remember_token',
34 ];
35
36 /**
37 * The attributes that should be cast.
38 *
39 * @var array<string, string>
40 */
41 protected $casts = [
42 'email_verified_at' => 'datetime',
43 ];
44
45 public function posts()
46 {
47 return $this->hasMany(Post::class);
48 }
49}

:::

:::details Post モデル

app/Models/Post.php
1<?php
2
3namespace App\Models;
4
5use Illuminate\Database\Eloquent\Factories\HasFactory;
6use Illuminate\Database\Eloquent\Model;
7
8class Post extends Model
9{
10 use HasFactory;
11
12 public function user()
13 {
14 return $this->belongsTo(User::class);
15 }
16}

:::

:::details PostController

app/Http/Controllers/PostController.php
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Http\Resources\PostResource;
6use App\Http\Usecases\Post\IndexAction;
7use App\Models\Post;
8
9class PostController extends Controller
10{
11 /**
12 * Display a listing of the resource.
13 *
14 * @return \Illuminate\Http\Response
15 */
16 public function index(IndexAction $action)
17 {
18 return PostResource::collection($action());
19 }
20}

:::

:::details PostResource

app/Resources/PostResource.php
1<?php
2
3namespace App\Http\Resources;
4
5use Illuminate\Http\Resources\Json\JsonResource;
6
7class PostResource extends JsonResource
8{
9 public function toArray($request): array
10 {
11 parent::withoutWrapping();
12 assert($this->resource->relationLoaded('user'));
13
14 return [
15 'id' => $this->resource->id,
16 'title' => $this->resource->title,
17 'content' => $this->resource->content,
18 'author' => $this->resource->user->name,
19 'imageUrl' => $this->resource->user->imageUrl,
20 'created_at' => $this->resource->created_at,
21 'updated_at' => $this->resource->updated_at
22 ];
23 }
24}

:::

paginate()メソッドを使えばいい

とりあえずpaginate()メソッドを使えばいい

app/Http/Usecases/Post/IndexAction.php
1<?php
2
3namespace App\Http\Usecases\Post;
4
5use App\Models\Post;
6
7class IndexAction
8{
9 public function __invoke()
10 {
11 return Post::with('user:id,name') // userモデルから取得するカラムをid、nameに制限する
12 ->paginate(10); // 1ページ内の量は10個
13 }
14}

レスポンスの形(インターフェース)に注意する

PostControllerPostResourceは以下のようになっていた。

app/Http/Controllers/PostController.php
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Http\Resources\PostResource;
6use App\Http\Usecases\Post\IndexAction;
7use App\Models\Post;
8
9class PostController extends Controller
10{
11 /**
12 * Display a listing of the resource.
13 *
14 * @return \Illuminate\Http\Response
15 */
16 public function index(IndexAction $action)
17 {
18 // $action()の返り値をPostResourceの形に整形
19 return PostResource::collection($action());
20 }
21}
app/Resources/PostResource.php
1<?php
2
3namespace App\Http\Resources;
4
5use Illuminate\Http\Resources\Json\JsonResource;
6
7class PostResource extends JsonResource
8{
9 public function toArray($request): array
10 {
11 parent::withoutWrapping();
12
13 // PostResourceの形は以下のように定める(APIレスポンスのインターフェース)
14 return [
15 'id' => $this->resource->id,
16 'title' => $this->resource->title,
17 'content' => $this->resource->content,
18 'author' => $this->resource->user->name,
19 'imageUrl' => $this->resource->user->imageUrl,
20 'created_at' => $this->resource->created_at,
21 'updated_at' => $this->resource->updated_at
22 ];
23 }
24}

しかし、レスポンスは以下のようになる

1{
2 "data": [
3 {
4 // ここの1つ1つがPostResourceの形に整形される
5 "id": 11,
6 "title": "た叫さけんか。わたしたらこの下の向むこ。",
7 "content": "置おいのるようと、あらわれるようにかかり、黄いおうでが、まだ小さく遠く遠いもりのつい顔のやぶをまた何気なくなって立ってじっけて見分けてありが非常ひじょうのての」ジョバンニは、次つぎへとたべるような音が川の左の岸きしに行こうけんを出してそらの木といいえ、しばらの礫こいしょうどうした。二人ふたごのお宮みやだ。だまにもっとそろえているもんですって来るのを見てあの図よりは眼めをこらは白鳥の停車場ているのでした。ほんとうち、次つぎへとたべているんでした。(ああ、そのなかにあれ」睡ねむらされ汽車やその大きなりました。とこらのなかに赤い旗はたをあい悪わるがえて、家庭教師かていたり、カムパネルラが答えましたりましく泣なきだして二人ふたり、どうだいと叫さけびましていました。とこへ行くと同じいろの空から出て来るか、あなた方はガラスの鎖くさんが迎むかしの木がほんとした。そしてジョバンニは思わずかな銀河ぎんがを大きなり、大将たいだねえ」「ザウエルとても誰だれてカムパネルラが不思議ふしぎなんぞたべてにげたりのうしろに人のインデアンの塊かたいどこかそうに、「ああ、どうしていまにそう感じてんてんきり六十度どばかり。",
8 "author": "近藤 里佳",
9 "imageUrl": "https://via.placeholder.com/640x480.png/001100?text=a",
10 "created_at": "2023-01-07T08:48:22.000000Z",
11 "updated_at": "2023-01-07T08:48:22.000000Z"
12 },
13 {
14 "id": 12,
15 "title": "いねいに深ふかいのってじっと光りません。",
16 "content": "ょうへ行って、ただ眼めをカムパネルラは、指ゆびできました。いいました。あの汽車は降おりような声が聞こえたちや町の家々ではね、わあとだなのが水からここどもたれだんゆるやかに爆発ばくはそのまん中にかの花が、ジョバンニは手を振ふりますとみちを乗のって地球ちきゅうに急いそよりも見え、きれいな涙なみちがそのひとそろそろそうとしてやすみました。カムパネルラの行ったのでしたがたくをあけて死しぬったんそうだ」「それを見ました。その小さな青じろいろいはげしい力が湧わき、「ザネリがばっているかぐあい悪わるがわるがわかり、また鳥をとって歴史れきっとまりませんかくの青年が祈いのはらを光らしだったり、スコップではいていしょうへいたことも言いいないる。けれどもらは、だまってしませんろと青じろいの火は燃もえたふくをして、前の言いったよ。お前は夢ゆめをこすりへらさきの波なみだよ」「鶴つるでひるまのお母っから、その白い鳥の形になり、カムパネルラのお菓子かしのずうっとそうようなんべんも出たとき汽車は、お父さんの円光をいじょうだ、やさしているのです」「ああ、ぼんやりしても気持きも切れず膝ひざまのまって大きな蟹かになるなら。",
17 "author": "近藤 里佳",
18 "imageUrl": "https://via.placeholder.com/640x480.png/001100?text=a",
19 "created_at": "2023-01-07T08:48:22.000000Z",
20 "updated_at": "2023-01-07T08:48:22.000000Z"
21 }
22 // 省略
23 ],
24 // 以下はページネーションを実装するために必要な情報を自動で付与してくれる
25 "links": {
26 "first": "http://localhost/api/post?page=1",
27 "last": "http://localhost/api/post?page=10",
28 "prev": "http://localhost/api/post?page=1",
29 "next": "http://localhost/api/post?page=3"
30 },
31 "meta": {
32 "current_page": 2,
33 "from": 11,
34 "last_page": 10,
35 "links": [
36 {
37 "url": "http://localhost/api/post?page=1",
38 "label": "pagination.previous",
39 "active": false
40 },
41 {
42 "url": "http://localhost/api/post?page=1",
43 "label": "1",
44 "active": false
45 },
46 {
47 "url": "http://localhost/api/post?page=2",
48 "label": "2",
49 "active": true
50 },
51 {
52 "url": "http://localhost/api/post?page=3",
53 "label": "3",
54 "active": false
55 },
56 {
57 "url": "http://localhost/api/post?page=4",
58 "label": "4",
59 "active": false
60 },
61 {
62 "url": "http://localhost/api/post?page=5",
63 "label": "5",
64 "active": false
65 },
66 {
67 "url": "http://localhost/api/post?page=6",
68 "label": "6",
69 "active": false
70 },
71 {
72 "url": "http://localhost/api/post?page=7",
73 "label": "7",
74 "active": false
75 },
76 {
77 "url": "http://localhost/api/post?page=8",
78 "label": "8",
79 "active": false
80 },
81 {
82 "url": "http://localhost/api/post?page=9",
83 "label": "9",
84 "active": false
85 },
86 {
87 "url": "http://localhost/api/post?page=10",
88 "label": "10",
89 "active": false
90 },
91 {
92 "url": "http://localhost/api/post?page=3",
93 "label": "pagination.next",
94 "active": false
95 }
96 ],
97 "path": "http://localhost/api/post",
98 "per_page": 10,
99 "to": 20,
100 "total": 100
101 }
102}

つまり、paginator を Resource に渡すと自動でページネーションを実装するのに必要な情報が付与される

これに関しては以下の記事が参考になる: https://zenn.dev/tekihei2317/articles/7bb54c6d5a340e

フロントエンド側で型を定義しておく

フロント用で適当に以下のような型を定義しておくといい気がする

types/dataWithPagination.ts
1export type DataWithPagination<T> = {
2 data: T; // 本体データの型をジェネリックスで渡す
3 // 以下はページネーションを実装するのに必要な情報たち
4 links: {
5 first: string;
6 last: string;
7 prev: string;
8 next: string;
9 };
10 meta: {
11 current_page: number;
12 last_page: number;
13 path: string; // apiのpath(http://example.com/api/postなど)
14 per_page: number;
15 from: number; // 何番目のデータから始まるか
16 to: number; // 何番目のデータまでか
17 total: number;
18 links: {
19 url: string;
20 label: string;
21 active: boolean;
22 }[];
23 };
24};

Post の型も適当に定義しておく

types/post.ts
1export type Post = {
2 id: string;
3 title: string;
4 content: string;
5 author: string;
6 imageUrl: string;
7 created_at: string;
8 updated_at: string;
9};

SWR を使ってデータフェッチとページネーションを楽に実装する

SWR を使ってデータフェッチを行うと楽な気がする。 SWR の fetcher には axios を使う。

lib/axios.ts
1import Axios from 'axios'
2
3const axios = Axios.create({
4 baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, // laravelのURL
5 headers: {
6 'X-Requested-With': 'XMLHttpRequest',
7 },
8 withCredentials: true,
9})
10
11export default axios
hooks/posts.ts
1import { DataWithPagination } from "../types/dataWithPagination";
2import axios from "@/lib/axios";
3import useSWR, { Fetcher } from "swr";
4import { Post } from "@/types/Post";
5
6// データはPost[]型 + ページネーションを実装するのに必要な情報の型
7type ReturnType = DataWithPagination<Post[]>;
8
9// pageを引数に受け取る(component側で渡す想定)
10export const usePosts = (page: number) => {
11 const fetcher: Fetcher<ReturnType, string> = async (url) => {
12 const res = await axios.get(url);
13 const data = await res.data;
14
15 return data;
16 };
17
18 const { data, error, isLoading } = useSWR(`/api/post?page=${page}`, fetcher);
19
20 return {
21 data,
22 error,
23 isLoading,
24 };
25};

SWR を用いたページネーションの実装については以下が参考になる: https://swr.vercel.app/ja/docs/pagination

最新の投稿