0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React + MUI で「ユーザー一覧」にページネーションと名前検索を追加する

0
Posted at

はじめに

ユーザー一覧を作る場合、最初は取得したデータをそのまま全件表示しがちである。

しかし、表示件数が増えてくると、以下のような問題が出やすい。

・一覧画面が縦に長くなる
・目的のユーザーを探しにくい
・Card や ListItem の描画数が増えて画面が少し重くなる
・ユーザー体験として一覧性が悪くなる

今回は、ユーザー一覧 に以下の 2 つを追加する。

・名前検索
・ページネーション

今回はバックエンド側の検索 API は追加せず、取得済みの一覧データをフロントエンド側で絞り込み・ページ分割する

想定規模は、利用ユーザー数が 1000 人程度のケースである。
この程度であれば、フロントエンド側の filterslice でも十分扱いやすい。


実装方針

今回の方針は以下である。

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 の PaperBox を使って、少し余白を持たせてもよい。

{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 を追加するだけで、ユーザーが目的の人物を探しやすくなり、画面の見通しもかなり改善される。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?