はじめに
ユーザー一覧を作る場合、最初は取得したデータをそのまま全件表示しがちである。
しかし、表示件数が増えてくると、以下のような問題が出やすい。
・一覧画面が縦に長くなる
・目的のユーザーを探しにくい
・Card や ListItem の描画数が増えて画面が少し重くなる
・ユーザー体験として一覧性が悪くなる
今回は、ユーザー一覧 に以下の 2 つを追加する。
・名前検索
・ページネーション
今回はバックエンド側の検索 API は追加せず、取得済みの一覧データをフロントエンド側で絞り込み・ページ分割する。
想定規模は、利用ユーザー数が 1000 人程度のケースである。
この程度であれば、フロントエンド側の filter と slice でも十分扱いやすい。
実装方針
今回の方針は以下である。
1. API からつながり一覧を取得する
2. フロントエンド側で名前検索する
3. 検索後の結果をページネーションで分割する
4. 1ページあたりの表示件数を固定する
処理の順番としては、以下のようにする。
全データ
↓
名前検索
↓
ページネーション
↓
表示
重要なのは、先に検索してから pagination する ことである。
先に pagination してから検索すると、「現在のページの中だけを検索する」挙動になってしまう。
通常は、一覧全体から検索して、その検索結果をページ分割する方が自然である。
前提の型
例として、つながり一覧のデータ型を以下のようにする。
export type Connection = {
userId: number;
name: string;
department?: string;
profileIconUrl?: string;
bio?: string;
};
実際には、アプリケーションに合わせて以下のような項目を持たせてもよい。
・参加イベント数
・共通イベント数
・部署
・タグ
・Slack ID
・プロフィール画像 URL
今回は名前検索を行うため、少なくとも name は必要である。
state を用意する
一覧データ、検索文字列、現在のページを state で管理する。
const [connections, setConnections] = useState<Connection[]>([]);
const [searchText, setSearchText] = useState("");
const [page, setPage] = useState(1);
1ページあたりの表示件数も定義しておく。
const ITEMS_PER_PAGE = 20;
1000 人程度の一覧であれば、1ページ 20 件から 50 件程度が扱いやすい。
20件:
画面が軽く、見やすい
50件:
ページ移動は少なくなるが、1ページがやや長くなる
今回は 20 件にする。
名前検索を実装する
まず、検索文字列を正規化する。
const keyword = searchText.trim().toLowerCase();
そのうえで、名前に検索文字列が含まれているかを判定する。
const filteredConnections = connections.filter((connection) =>
connection.name.toLowerCase().includes(keyword)
);
ただし、このまま毎回計算しても大きな問題はないが、一覧データがある程度増えることを考え、useMemo を使う。
const filteredConnections = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
if (!keyword) {
return connections;
}
return connections.filter((connection) =>
connection.name.toLowerCase().includes(keyword)
);
}, [connections, searchText]);
useMemo を使うことで、connections または searchText が変わった時だけ再計算される。
1000 件程度であれば必須ではないが、一覧系の画面では入れておくと扱いやすい。
検索時に page を 1 に戻す
検索文字列が変わったとき、現在のページを 1 に戻す。
例えば、5ページ目を表示している状態で検索すると、検索結果が 1ページ分しかないのに 5ページ目を見ようとしてしまう可能性がある。
そのため、検索入力時に setPage(1) する。
function handleSearchChange(event: React.ChangeEvent<HTMLInputElement>) {
setSearchText(event.target.value);
setPage(1);
}
ページネーション用の値を計算する
検索後の件数から、総ページ数を計算する。
const totalPages = Math.ceil(filteredConnections.length / ITEMS_PER_PAGE);
ただし、結果が 0 件の場合に totalPages が 0 になるため、MUI の Pagination に渡す値としては最低 1 にしておくと安全である。
const totalPages = Math.max(
1,
Math.ceil(filteredConnections.length / ITEMS_PER_PAGE)
);
現在のページに表示するデータは、slice で取り出す。
const paginatedConnections = filteredConnections.slice(
(page - 1) * ITEMS_PER_PAGE,
page * ITEMS_PER_PAGE
);
これで、例えば ITEMS_PER_PAGE = 20 の場合は以下のようになる。
page = 1:
0件目から19件目
page = 2:
20件目から39件目
page = 3:
40件目から59件目
検索と pagination をまとめる
最終的に、以下のように書ける。
const ITEMS_PER_PAGE = 20;
const [connections, setConnections] = useState<Connection[]>([]);
const [searchText, setSearchText] = useState("");
const [page, setPage] = useState(1);
const filteredConnections = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
if (!keyword) {
return connections;
}
return connections.filter((connection) =>
connection.name.toLowerCase().includes(keyword)
);
}, [connections, searchText]);
const totalPages = Math.max(
1,
Math.ceil(filteredConnections.length / ITEMS_PER_PAGE)
);
const paginatedConnections = filteredConnections.slice(
(page - 1) * ITEMS_PER_PAGE,
page * ITEMS_PER_PAGE
);
function handleSearchChange(event: React.ChangeEvent<HTMLInputElement>) {
setSearchText(event.target.value);
setPage(1);
}
function handlePageChange(
event: React.ChangeEvent<unknown>,
value: number
) {
setPage(value);
}
handlePageChange の第1引数 event を使わない場合は、以下のように _event と書いてもよい。
function handlePageChange(
_event: React.ChangeEvent<unknown>,
value: number
) {
setPage(value);
}
MUI の検索フォーム
検索フォームは TextField で作る。
<TextField
label="名前で検索"
placeholder="名前を入力"
size="small"
value={searchText}
onChange={handleSearchChange}
fullWidth
/>
一覧画面の上部に置くとよい。
例えば、タイトルと検索欄を以下のように配置する。
<Stack spacing={2}>
<Box>
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
つながり一覧
</Typography>
<Typography variant="body2" color="text.secondary">
表示中のつながりを名前で検索できます。
</Typography>
</Box>
<TextField
label="名前で検索"
placeholder="名前を入力"
size="small"
value={searchText}
onChange={handleSearchChange}
fullWidth
/>
</Stack>
検索結果件数を表示する
検索や pagination を入れる場合、現在何件表示されているかを出すと分かりやすい。
<Typography variant="body2" color="text.secondary">
{filteredConnections.length}件中{" "}
{paginatedConnections.length}件を表示
</Typography>
検索文字列がある場合だけ、以下のように表示してもよい。
{searchText && (
<Typography variant="body2" color="text.secondary">
「{searchText}」の検索結果: {filteredConnections.length}件
</Typography>
)}
一覧表示
表示には paginatedConnections を使う。
{paginatedConnections.map((connection) => (
<ConnectionCard
key={connection.userId}
connection={connection}
/>
))}
filteredConnections ではなく、必ず paginatedConnections を使う。
// 全検索結果が表示されてしまう
filteredConnections.map(...)
// 現在のページ分だけ表示される
paginatedConnections.map(...)
検索結果が 0 件の場合
検索結果がない場合は、メッセージを表示する。
{filteredConnections.length === 0 ? (
<Typography color="text.secondary">
該当するユーザーが見つかりません
</Typography>
) : (
paginatedConnections.map((connection) => (
<ConnectionCard
key={connection.userId}
connection={connection}
/>
))
)}
MUI の Paper や Box を使って、少し余白を持たせてもよい。
{filteredConnections.length === 0 && (
<Box
sx={{
py: 4,
textAlign: "center",
}}
>
<Typography color="text.secondary">
該当するユーザーが見つかりません
</Typography>
</Box>
)}
Pagination を表示する
MUI の Pagination を使う。
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
中央に配置したい場合は、Stack で囲む。
{filteredConnections.length > 0 && (
<Stack alignItems="center" sx={{ mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Stack>
)}
ページ数が 1 のときは表示しない、という設計もできる。
{totalPages > 1 && (
<Stack alignItems="center" sx={{ mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Stack>
)}
全体の実装例
全体としては、以下のように実装できる。
import { useMemo, useState } from "react";
import {
Box,
Pagination,
Stack,
TextField,
Typography,
} from "@mui/material";
type Connection = {
userId: number;
name: string;
department?: string;
profileIconUrl?: string;
bio?: string;
};
type Props = {
connections: Connection[];
};
const ITEMS_PER_PAGE = 20;
export function ConnectionList({ connections }: Props): JSX.Element {
const [searchText, setSearchText] = useState("");
const [page, setPage] = useState(1);
const filteredConnections = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
if (!keyword) {
return connections;
}
return connections.filter((connection) =>
connection.name.toLowerCase().includes(keyword)
);
}, [connections, searchText]);
const totalPages = Math.max(
1,
Math.ceil(filteredConnections.length / ITEMS_PER_PAGE)
);
const paginatedConnections = filteredConnections.slice(
(page - 1) * ITEMS_PER_PAGE,
page * ITEMS_PER_PAGE
);
function handleSearchChange(event: React.ChangeEvent<HTMLInputElement>) {
setSearchText(event.target.value);
setPage(1);
}
function handlePageChange(
_event: React.ChangeEvent<unknown>,
value: number
) {
setPage(value);
}
return (
<Stack spacing={3}>
<Box>
<Typography variant="h5" sx={{ fontWeight: "bold" }}>
つながり一覧
</Typography>
<Typography variant="body2" color="text.secondary">
表示中のつながりを名前で検索できます。
</Typography>
</Box>
<TextField
label="名前で検索"
placeholder="名前を入力"
size="small"
value={searchText}
onChange={handleSearchChange}
fullWidth
/>
<Typography variant="body2" color="text.secondary">
{filteredConnections.length}件中{" "}
{paginatedConnections.length}件を表示
</Typography>
{filteredConnections.length === 0 ? (
<Box
sx={{
py: 4,
textAlign: "center",
}}
>
<Typography color="text.secondary">
該当するユーザーが見つかりません
</Typography>
</Box>
) : (
<Stack spacing={2}>
{paginatedConnections.map((connection) => (
<ConnectionCard
key={connection.userId}
connection={connection}
/>
))}
</Stack>
)}
{totalPages > 1 && (
<Stack alignItems="center" sx={{ mt: 2 }}>
<Pagination
count={totalPages}
page={page}
onChange={handlePageChange}
color="primary"
/>
</Stack>
)}
</Stack>
);
}
ConnectionCard は、各アプリケーションに合わせて実装する。
例えば、以下のような簡単なカードを使える。
import {
Avatar,
Card,
CardContent,
Stack,
Typography,
} from "@mui/material";
type ConnectionCardProps = {
connection: Connection;
};
function ConnectionCard({ connection }: ConnectionCardProps): JSX.Element {
return (
<Card variant="outlined">
<CardContent>
<Stack direction="row" spacing={2} alignItems="center">
<Avatar src={connection.profileIconUrl}>
{connection.name.charAt(0)}
</Avatar>
<Box>
<Typography sx={{ fontWeight: "bold" }}>
{connection.name}
</Typography>
{connection.department && (
<Typography variant="body2" color="text.secondary">
{connection.department}
</Typography>
)}
{connection.bio && (
<Typography variant="body2" color="text.secondary">
{connection.bio}
</Typography>
)}
</Box>
</Stack>
</CardContent>
</Card>
);
}
1000人程度ならフロントエンド検索でもよい
今回のように、利用人数が 1000 人程度であれば、フロントエンド側での名前検索と pagination は十分現実的である。
100〜500人:
ほぼ問題ない
1000人程度:
useMemo と pagination を入れれば扱いやすい
数千人以上:
サーバーサイド検索や API 側の pagination を検討する
特に、すでに一覧データを全件取得している場合、フロントエンド側で filter を追加しても大きな負荷増にはなりにくい。
むしろ、全件を一度に画面に描画する方が重くなりやすい。
そのため、検索だけでなく pagination も入れて、実際に描画する件数を制限するのが有効である。
フロントエンド pagination の限界
今回の実装は、あくまで取得済みデータに対する pagination である。
つまり、API がすでに 1000 件を返している場合、その 1000 件をフロントエンド側でページ分割している。
API:
全件取得
Frontend:
filter
slice
display
この方式は実装が簡単で、1000 人程度であれば扱いやすい。
一方で、以下のような場合はサーバーサイド pagination を検討する。
・ユーザー数が数千〜数万になる
・API response が大きすぎる
・初回表示が遅い
・検索対象が「取得済みデータ」ではなく「DB 全体」である必要がある
・ページごとに最新データを取得したい
その場合は、例えば以下のような API 設計にする。
GET /api/connections?keyword=tanaka&page=1&size=20
ただし、リリース前の段階や利用者数が 1000 人程度の段階では、まずフロントエンド側で pagination と名前検索を入れるだけでも十分効果がある。
実装時の注意点
検索してから pagination する
順番は以下が自然である。
connections
↓
filter by name
↓
slice by page
逆にすると、現在のページ内だけを検索する挙動になってしまう。
検索時は page を 1 に戻す
検索文字列が変わったら、現在ページを 1 に戻す。
function handleSearchChange(event: React.ChangeEvent<HTMLInputElement>) {
setSearchText(event.target.value);
setPage(1);
}
これをしないと、検索結果が少ないのに存在しないページを表示しようとしてしまうことがある。
表示件数を固定する
1ページあたりの表示件数を固定しておくと、画面の高さや操作感が安定する。
const ITEMS_PER_PAGE = 20;
ユーザー一覧の場合、20 件から 50 件程度が扱いやすい。
まとめ
今回は、つながり一覧 に名前検索と pagination を追加した。
実装のポイントは以下である。
・検索文字列を state で管理する
・検索結果は useMemo で計算する
・検索してから pagination する
・pagination は slice で現在ページ分だけ取り出す
・検索文字列が変わったら page を 1 に戻す
・MUI の TextField と Pagination を使う
・1000人程度であればフロントエンド検索でも十分扱いやすい
一覧画面は、データを表示するだけでも成立する。
しかし、名前検索と pagination を追加するだけで、ユーザーが目的の人物を探しやすくなり、画面の見通しもかなり改善される。