[React] APIで取得したデータをタブ切り替え、リアルタイム検索してみる

# はじめに
アウトプット実践編として、APIで取得、タブ切り替え、リアルタイム検索をやってみたら色々勉強になったので記事にまとめようと思う。
# 環境
### 対応OS
* Mac OS
### 対応バージョン
* React 18.3.1
* typescript 5.6.2
### 対応エディタ
* VSCode

# 概要
* ユーザー一覧と投稿一覧をタブで出し分け
* ユーザー一覧はid、名前、メールアドレスを表示、投稿一覧はid、タイトル、内容を表示
* それぞれに検索バーを設けて、ユーザー一覧の場合は名前を、投稿一覧の場合は内容をリアルタイム検索できるようにする
* データ取得は以下URLから取得
    * ユーザー一覧: https://jsonplaceholder.typicode.com/users
    * 投稿一覧: https://jsonplaceholder.typicode.com/posts
* CSSはtailwindcss v4

# 作業手順
* APIでbaseURLを設定
* APIで投稿一覧を取得
    * その中からID タイトル 内容を表示
* APIでユーザー一覧を取得
    * その中からID 名前 メールアドレスを取得
* リアルタイム検索機能を実装
* ボタンで、投稿一覧とユーザー一覧を切り替える

## 1. 環境構築
```js:terminal
 npm create vite@latest
```
:::note info
プロジェクト名はカレントディレクトリ、使用するフレームワークはreact、TypeScriptを使用。
✔ Project name: … .
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
:::

### tailwindcss(v4)導入
```js:terminal
 npm install tailwindcss @tailwindcss/vite
```
vite.config.tsに追記
```ts:vite.config.ts
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
  plugins: [
    tailwindcss(),
  ],
})
```
src/index.cssを書き換え
```css:src/index.css
@import "tailwindcss";
```

```js:terminal
 npm run dev
```

## 2. APIでbaseURLを設定
まず、JavaScriptで使えるHTTPクライアントライブラリ`axios`をダウンロード。

https://github.com/axios/axios

```js:terminal
 npm install axios
```
`https://jsonplaceholder.typicode.com/`の部分は共通しているのでコードを簡略化＆再利用しやすくするためbaseURLを設定。
```ts:src/utils/api.ts
// axiosライブラリのインポートとAxiosインスタンスの作成
import axios from 'axios';

export default axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com'
})
```
## 3. APIで各一覧を取得
tableのデザインはtailwindcssのコンポーネントとセットになっているFlowBiteを使用してスタイリング。
`https://flowbite.com/docs/components/tables/`
### 投稿一覧
```tsx:src/pages/PostList.tsx
import { useEffect, useState } from 'react';
import api from '../utils/api';

type Post = {
  id: number;
  title: string;
  body: string;
};

export const PostList = () => {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    api
      .get('/posts')
      .then((response) => setPosts(response.data))
      .catch((error) => console.error('Error fetching posts:', error));
  }, []);

  return (
    <div className="relative overflow-x-auto">
      <table className="w-full text-sm text-left rtl:text-right text-gray-500">
        <thead className="text-xs text-gray-700 uppercase bg-gray-200">
          <tr>
            <th scope="col" className="px-6 py-3">
              id
            </th>
            <th scope="col" className="px-6 py-3">
              タイトル
            </th>
            <th scope="col" className="px-6 py-3">
              内容
            </th>
          </tr>
        </thead>
        <tbody>
          {posts.map((post) => (
            <tr
              key={post.id}
              className="bg-white border-b"
            >
              <td className="px-6 py-4">{post.id}</td>
              <td className="px-6 py-4">{post.title}</td>
              <td className="px-6 py-4">{post.body}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};
```
### ユーザー一覧
```tsx:src/pages/UserList.tsx
import { useEffect, useState } from 'react';
import api from '../utils/api';

type User = {
  id: number;
  name: string;
  email: string;
}

export const UserList = () => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    api.get('/users')
    .then(response => setUsers(response.data))
    .catch(error => console.error('Error fetching data:', error))
 }, []);

  return (
    <div className="relative overflow-x-auto">
      <table className="w-full text-sm text-left rtl:text-right text-gray-500">
        <thead className="text-xs text-gray-700 uppercase bg-gray-200">
          <tr>
            <th scope="col" className="px-6 py-3">
              id
            </th>
            <th scope="col" className="px-6 py-3">
              名前
            </th>
            <th scope="col" className="px-6 py-3">
              メールアドレス
            </th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr
              key={user.id}
              className="bg-white border-b"
            >
              <td className="px-6 py-4">{user.id}</td>
              <td className="px-6 py-4">{user.name}</td>
              <td className="px-6 py-4">{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};
```
## 4. リアルタイム検索機能を実装
* App.tsx
useState を使って keyword（検索キーワード）の状態を管理。
```tsx:src/App.tsx
import { useState } from 'react';
import './App.css'
import { SearchBox } from './components/SearchBox';
import { PostList } from './pages/PostList';
import { UserList } from './pages/UserList';

function App() {
  const [keyword, setKeyword] = useState<string>('');

  return (
    <>
      <SearchBox keyword={keyword} setKeyword={setKeyword} />
      <PostList keyword={keyword}  />
      <UserList keyword={keyword} />
    </>
  )
}

export default App
```
* SearchBox.tsx
keyword（現在の検索文字列）とsetKeyword（検索文字列を更新する関数）を受け取る。入力値が変わると、setKeywordで更新される。
```tsx:src/components/SearchBox.tsx
import { FC } from 'react';

type SearchBoxProps = {
  keyword: string;
  setKeyword: React.Dispatch<React.SetStateAction<string>>;
};

export const SearchBox: FC<SearchBoxProps> = ({ keyword, setKeyword }) => {
  return (
    <div className="pb-4 bg-white max-w-md mx-auto">
      <label htmlFor="table-search" className="sr-only">Search</label>
      <div className="relative mt-1">
        <div className="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
          <svg className="w-4 h-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
            <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
          </svg>
        </div>
        <input
          type="text"
          id="table-search"
          className="block py-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-full bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
          placeholder="検索キーワードを入力"
          value={keyword}
          onChange={(e) => setKeyword(e.target.value)}
        />
      </div>
    </div>
  );
};
```
* PostList.tsx
useEffectを使ってAPIから投稿データを取得し、postsに保存。
keywordが変更されたときにfilteredPostsを更新し、検索結果のみ表示。
.toLowerCase()を使い、大文字・小文字の違いを無視して検索できるようする。
bodyに対して検索を行う。
```tsx:src/pages/PostList.tsx
import { useEffect, useState, FC, useMemo } from 'react';
import api from '../utils/api';

type Post = {
  id: number;
  title: string;
  body: string;
};

type PostListProps = {
  keyword: string;
};

export const PostList: FC<PostListProps> = ({ keyword }) => {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    api.get('/posts')
       .then(response => setPosts(response.data))
       .catch(error => console.error('Error fetching posts:', error));
  }, []);

  const filteredPosts = useMemo(() => {
    return posts.filter(post =>
      post.body.toLowerCase().includes(keyword.toLowerCase())
    );
  }, [keyword, posts]);

  return (
    <div className="relative overflow-x-auto">
      <table className="w-full text-sm text-left rtl:text-right text-gray-500">
        <thead className="text-xs text-gray-700 uppercase bg-gray-50">
          <tr>
            <th className="px-6 py-3">ID</th>
            <th className="px-6 py-3">タイトル</th>
            <th className="px-6 py-3">内容</th>
          </tr>
        </thead>
        <tbody>
          {filteredPosts.map((post) => (
            <tr key={post.id} className="bg-white border-b">
              <td className="px-6 py-4">{post.id}</td>
              <td className="px-6 py-4">{post.title}</td>
              <td className="px-6 py-4">{post.body}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};
```

* UserList.tsx
PostList.tsxとほぼ同じ構造で、/usersからユーザー情報を取得。
nameに対して検索を行う。
```tsx:src/pages/UserList.tsx
import { useEffect, useState, FC, useMemo } from 'react';
import api from '../utils/api';

type User = {
  id: number;
  name: string;
  email: string;
};

type UserListProps = {
  keyword: string;
};

export const UserList: FC<UserListProps> = ({ keyword }) => {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    api.get('/users')
       .then(response => setUsers(response.data))
       .catch(error => console.error('Error fetching users:', error));
  }, []);

  const filteredUsers = useMemo(() => {
    return users.filter(user =>
      user.name.toLowerCase().includes(keyword.toLowerCase())
    );
  }, [keyword, users]);

  return (
    <div className="relative overflow-x-auto">
      <table className="w-full text-sm text-left rtl:text-right text-gray-500">
        <thead className="text-xs text-gray-700 uppercase bg-gray-50">
          <tr>
            <th className="px-6 py-3">ID</th>
            <th className="px-6 py-3">名前</th>
            <th className="px-6 py-3">メールアドレス</th>
          </tr>
        </thead>
        <tbody>
          {filteredUsers.map((user) => (
            <tr key={user.id} className="bg-white border-b">
              <td className="px-6 py-4">{user.id}</td>
              <td className="px-6 py-4">{user.name}</td>
              <td className="px-6 py-4">{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};
```
## 5. ボタンで、投稿一覧とユーザー一覧を切り替える
* TabButton.tsx
isActiveに応じて、ボタンのデザインを変更。
```tsx:src/components/TabButton.tsx
import React from 'react'

type TabButtonProps = {
  label: string;
  isActive: boolean;
  onClick: () => void;
};

export const TabButton: React.FC<TabButtonProps> = ({ label, isActive, onClick }) => {
  return (
    <button
      onClick={onClick}
      className={`inline-block border-solid border-1 border-gray-600 p-4 rounded ${
        isActive ? 'text-white bg-gray-600' : 'hover:text-gray-600 hover:bg-gray-50'
      }`}
    >
      {label}
    </button>
  )
}
```
* App.tsx
selectedTab（現在選択中のタブ）を userList（ユーザー一覧）か postList（投稿一覧）で管理。
selectedTab の値に応じて、表示するコンポーネントを切り替え。
```tsx:src/App.tsx
import { useState } from 'react';
import './App.css';
import { SearchBox } from './components/SearchBox';
import { PostList } from './pages/PostList';
import { UserList } from './pages/UserList';
import { TabButton } from './components/TabButton';

function App() {
  const [keyword, setKeyword] = useState<string>('');
  const [selectedTab, setSelectedTab] = useState<'userList' | 'postList'>('userList');

  return (
    <div className="container mx-auto p-4">
      <ul className="flex flex-wrap text-sm font-medium text-center text-gray-500 justify-center mb-4">
        <li className="me-2">
          <TabButton
            label="ユーザー一覧"
            isActive={selectedTab === 'userList'}
            onClick={() => setSelectedTab('userList')}
          />
        </li>
        <li className="me-2">
          <TabButton
            label="投稿一覧"
            isActive={selectedTab === 'postList'}
            onClick={() => setSelectedTab('postList')}
          />
        </li>
      </ul>
      <SearchBox keyword={keyword} setKeyword={setKeyword} />
      {selectedTab === 'userList' ? <PostList keyword={keyword} /> : <UserList keyword={keyword} />}
    </div>
  );
}

export default App;
```
![スクリーンショット 2025-02-02 0.33.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3458437/63dc7869-6e4e-6fcb-9baa-75a88d74fa67.png)

# 終わりに
インプットが済んであらかたできる気でいたが難しかった。
もっとアウトプットを増やして知識を身につけていきたい。