VS2022 17.8 の React テンプレート (Vite) でホロジュール Web アプリを作り直した

## はじめに

Visual Studio 2022 17.8 以降より、ASP.NET Core をバックエンドとした React/TypeScript Webアプリケーションを開発する際のテンプレートが Vite ベースになりました。

Vite や 標準で組み込まれた Swagger UI および HTTP リクエストファイルなどを試してみたいので、以前開発したホロジュールの React/TypeScript Webアプリケーションに検索機能を追加しつつ、新しいテンプレートを利用してあらためてゼロから開発してみました。

日付の移動だけではなく、グループの選択やキーワード検索、表示日数の変更を可能としています。

![img01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/b8e9b459-7872-308e-82cd-73a0f89ef22f.png)

## Vite とは

[Vite 次世代フロントエンドツール](https://ja.vitejs.dev/)

> Vite（フランス語で「素早い」という意味の単語で /vit/ ヴィートのように発音）は、現代の Web プロジェクトのために、より速く無駄のない開発体験を提供することを目的としたビルドツールです。

具体的には下記2つが主要な機能だそうです。

1. 非常に高速な Hot Module Replacement (HMR) など、ネイティブ ES モジュールを利用した豊富な機能拡張を提供する開発サーバー。

2. Rollup でコードをバンドルするビルドコマンド。プロダクション用に高度に最適化された静的アセットを出力するように事前に設定されています。

Viteの役割は大きくわけて2つあり、開発環境の構築と本番環境のビルドです。

本番環境のビルドについて、これまでの webpack のようなバンドラベースのビルドセットアップは、複数の JavaScript のコードを1つにまとめる（バンドルする）ことで、ブラウザで実行可能としていましたが、コード量が大きくなると結果としてアプリが重くなるという問題がありました。

Vite では、ネイティブ ESM を利用することで、ビルド後の生成物を複数ファイルやモジュールのままブラウザで直接読み込ませるため、アプリ全体をバンドルせず高速化しています。

Visual Studio 2022 17.8 以降では、「React and ASP.NET Code (TypeScript)」を選択してプロジェクトを作成することで、特に意識することなく Vite を利用できます。

## 開発環境

* Windows 11 23H2
* PowerShell 7.4.1
* Visual Studio 2022 17.8.7
* Node 20.11.0 + npm 10.2.4
* React 18.2.0 + React Router 6.22.0
* Chakra UI/React 2.8.2 + Chakra UI/Icons 2.1.1
* TypeScript 5.2.2
* ホロコレクト（[ホロジュール収集プログラム（Python）のアップデート](https://qiita.com/kerobot/items/c1944dc29d6de542ad86)）
* ホロサービス（[ホロジュール収集バックエンド API の FastAPI 化](https://qiita.com/kerobot/items/28b109c8c5736a692b95)）

## 作ったもの

https://github.com/kerobot/holoduler2024

## プロジェクトの作成

まずは node と npm が利用できることを確認しておきます。

```powershell
> node --version
v20.11.0
> npm --version
10.2.4
```

Visual Studio 2022 17.8.7 を利用して、プロジェクトテンプレートとして「React and ASP.NET Code (TypeScript)」を選択してプロジェクトを作成します。

ASP.NET のフレームワークには .NET 8 を選択しました。

プロジェクト作成後、パッケージをインストールするために一度ビルドしておきまます。

作成したプロジェクトはクライアント（React/TypeScript）側とサーバー側（ASP.NET Core Web API）に分かれていました。

```text
holoduler.client --> React / TypeScript
holoduler.Server --> ASP.NET Core Web API
```

package.json を開いてみると、react と typescript と vite が含まれています。

```json
{
  "name": "holoduler2024.client",
  "private": true,
  "version": "0.0.1",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.55",
    "@types/react-dom": "^18.2.19",
    "@typescript-eslint/eslint-plugin": "^6.21.0",
    "@typescript-eslint/parser": "^6.21.0",
    "@vitejs/plugin-react": "^4.2.1",
    "eslint": "^8.56.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.5",
    "typescript": "^5.2.2",
    "vite": "^5.1.0"
  }
}
```

vite.config.ts を開くと TS2580 が表示されるので、@types/node パッケージを追加しておきます。

```powershell
> npm add --include=dev @types/node@^20.11.17
```

certificateArg.groups の TS18048 は、とりあえず下記のように解消しておきます。

```typescript
// certificateArg.groups の null 判定を追加
const certificateName = certificateArg && certificateArg.groups ? certificateArg.groups.value : "holoduler.client";
```

それでは、クライアント側プロジェクトの設定ファイルを見ていきます。

## tsconfig.json

TypeScript を利用するための設定ファイル

```typescript
{
  // コンパイルオプション
  "compilerOptions": {
    // ECMAScript バージョン
    "target": "ES2020", 
    // クラスフィールドの定義を使用
    "useDefineForClassFields": true,
    // コンパイルで使用する組み込みライブラリ 
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    // 利用するモジュール
    "module": "ESNext",
    // 型チェックの精度を犠牲にしてコンパイル時間を削減するか
    "skipLibCheck": true,
    // モジュールをどのように解決するか
    "moduleResolution": "bundler",
    // import のパス名に .ts 拡張子を含めるか
    "allowImportingTsExtensions": true,
    // .json 拡張子を持つモジュールのインポートを許可するか
    "resolveJsonModule": true,
    // 正しく扱えない可能性のあるコードについて警告を出すか
    "isolatedModules": true,
    // JavaScriptを出力しないか
    "noEmit": true,
    // JSXコード生成の指定
    "jsx": "react-jsx",
    // コードを厳密にチェックするか
    "strict": true,
    // 使われていない変数を禁止するか
    "noUnusedLocals": true,
    // 使われていない引数を禁止するか
    "noUnusedParameters": true,
    // switch文のfallthrough（caseがbreakやreturnで終わらない）を禁止するか
    "noFallthroughCasesInSwitch": true
  },
  // コンパイルする対象（src配下のファイル全て）
  "include": ["src"],
  // Vite の設定ファイルを TypeScript で書くための設定を参照
  "references": [{ "path": "./tsconfig.node.json" }]
}
```

## tsconfig.node.json

Vite の設定ファイル（vite.config.ts）を TypeScript で書くための設定ファイル

```typescript
{
  // コンパイルオプション
  "compilerOptions": {
    // このプロジェクトが他のプロジェクトから参照されることを宣言
    "composite": true,
    // 型チェックの精度を犠牲にしてコンパイル時間を削減するか
    "skipLibCheck": true,
    // 利用するモジュール
    "module": "ESNext",
    // モジュールをどのように解決するか
    "moduleResolution": "bundler",
    // モジュールからのデフォルトのインポートを許可するか
    "allowSyntheticDefaultImports": true,
    // コードを厳密にチェックするか
    "strict": true
  },
  // Vite の設定ファイルを参照
  "include": ["vite.config.ts"]
}
```

## vite.config.ts

Vite の設定ファイル

```typescript
import { fileURLToPath, URL } from 'node:url';

import { defineConfig } from 'vite';
import plugin from '@vitejs/plugin-react';
import fs from 'fs';
import path from 'path';
import child_process from 'child_process';

// 基底フォルダパスを特定（APPDATE=Windows,HOME=Linux）
const baseFolder =
    process.env.APPDATA !== undefined && process.env.APPDATA !== ''
        ? `${process.env.APPDATA}/ASP.NET/https`
        : `${process.env.HOME}/.aspnet/https`;

// 証明書の名前を特定（既定でクライアント側プロジェクト名）
const certificateArg = process.argv.map(arg => arg.match(/--name=(?<value>.+)/i)).filter(Boolean)[0];
const certificateName = certificateArg && certificateArg.groups ? certificateArg.groups.value : "holoduler.client";

if (!certificateName) {
    console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass --name=<<app>> explicitly.')
    process.exit(-1);
}

// 証明書ファイルパス
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
// pemキーファイルパス
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);

// 証明書ファイルとpemキーファイルが存在する場合は
// dotnet dev-certs コマンドで、ローカル Web アプリの HTTPS を有効にする
if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
    if (0 !== child_process.spawnSync('dotnet', [
        'dev-certs',
        'https',
        '--export-path',
        certFilePath,
        '--format',
        'Pem',
        '--no-password',
    ], { stdio: 'inherit', }).status) {
        throw new Error("Could not create certificate.");
    }
}

// https://vitejs.dev/config/
export default defineConfig({
    // vitejs/plugin-react プラグインを使用する
    plugins: [plugin()],
    // パスエイリアスを設定する（@で./srcにアクセスできるようにする）
    resolve: {
        alias: {
            '@': fileURLToPath(new URL('./src', import.meta.url))
        }
    },
    server: {
        // 開発サーバーのカスタムプロキシのルールを設定する
        // これから作成するコントローラーにあわせて変更しておく
        proxy: {
            '^/api': {
                target: 'https://localhost:7025/',
                secure: false
            }
        },
        // サーバーのポートを指定する
        port: 5173,
        // TLS + HTTP/2 を有効にする（証明書ファイルとpemキーファイルを指定）
        https: {
            key: fs.readFileSync(keyFilePath),
            cert: fs.readFileSync(certFilePath),
        }
    }
})
```

## サーバー側の作成

サーバー側プロジェクトにコントローラーを追加して、Python で自作したホロサービスへ接続できるようにします。

### モデルの追加

まず、ホロサービスの認証に用いるトークン等のモデルを追加します。

holoduler.Server/Models/Auth.cs

```csharp
using Newtonsoft.Json;

namespace holoduler.Server.Models
{
    /// <summary>
    /// Web API に渡す認証情報を保持するクラス
    /// </summary>
    [JsonObject]
    public class Auth
    {
        [JsonProperty("username")]
        public string Username { get; private set; }

        [JsonProperty("password")]
        public string Password { get; private set; }

        public Auth(string username, string password)
        {
            Username = username;
            Password = password;
        }
    }
}
```

holoduler.Server/Models/Token.cs

```csharp
using Newtonsoft.Json;

namespace holoduler.Server.Models
{
    /// <summary>
    /// Web API から返却されるアクセストークンを保持するクラス
    /// </summary>
    [JsonObject]
    public class Token
    {
        [JsonProperty("access_token")]
        public string? AccessToken { get; private set; }

        [JsonProperty("token_type")]
        public string? TokenType { get; private set; }

        public Token()
        {
        }
    }
}
```

### データサービスの追加

ホロサービスへ接続する際の設定情報を扱う、サービスコンテナへ登録するサービスのインターフェースとクラスを作成します。

holoduler.Server\Services\IDataService.cs

```csharp
namespace holoduler.Server.Services
{
    /// <summary>
    /// Web API で利用する設定情報を扱う、サービスコンテナへ登録するサービスのインターフェース
    /// </summary>
    public interface IDataService
    {
        string UserName { get; }
        string Password { get; }
        string Endpoint { get; }
    }
}
```

holoduler.Server\Services\DataService.cs

```csharp
namespace holoduler.Server.Services
{
    /// <summary>
    /// Web API で利用する設定情報を扱う、サービスコンテナへ登録するサービスクラス
    /// </summary>
    public class DataService : IDataService
    {
        private readonly string _userName;
        private readonly string _password;
        private readonly string _endpoint;

        public DataService(string userName, string password, string endpoint)
        {
            _userName = userName;
            _password = password;
            _endpoint = endpoint;
        }

        public string UserName => _userName;
        public string Password => _password;
        public string Endpoint => _endpoint;
    }
}
```

### Program.cs の構成

Program.cs を構成します。

holoduler.Server\Program.cs

```csharp
using holoduler.Server.Services;

var builder = WebApplication.CreateBuilder(args);

// 環境変数から取得
var userName = Environment.GetEnvironmentVariable("API_USERNAME")!;
var password = Environment.GetEnvironmentVariable("API_PASSWORD")!;
var endpoint = Environment.GetEnvironmentVariable("API_ENDPOINT")!;

// サービスコンテナへ登録
builder.Services.AddTransient<IDataService>(_ => new DataService(userName, password, endpoint));
// コントローラーを使用
builder.Services.AddControllers();
// Swaggerを生成
builder.Services.AddSwaggerGen();
// CORS を追加
builder.Services.AddCors(options =>
{
    options.AddPolicy(
        "AllowAll",
        builder =>
        {
            // すべてのオリジンからのアクセスを許可
            builder.AllowAnyOrigin()
                   .AllowAnyMethod()
                   .AllowAnyHeader();
        });
});

// アプリケーションを構築
var app = builder.Build();

// 開発モードの場合 Swagger を使用
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

// CORS を許可
app.UseCors("AllowAll");
// デフォルトファイルを使用
app.UseDefaultFiles();
// 静的ファイルを使用
app.UseStaticFiles();
// HTTP 要求を HTTPS へリダイレクト
app.UseHttpsRedirection();
// 認証を使用
app.UseAuthorization();
// コントローラーを使用
app.MapControllers();
// フォールバックファイルを使用
app.MapFallbackToFile("/index.html");
// アプリケーションを実行
app.Run();
```

### コントローラーの追加

ホロサービスからトークンを取得し、そのトークンと指定されたパラメーターを用いてスケジュールを取得するコントローラーを追加します。

holoduler.Server\Controllers\HoloduleController.cs

```csharp
using holoduler.Server.Models;
using holoduler.Server.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using RestSharp;

namespace holoduler.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class HoloduleController : ControllerBase
    {
        private readonly IDataService _dataService;
        private readonly ILogger<HoloduleController> _logger;

        public HoloduleController(ILogger<HoloduleController> logger, IDataService dataService)
        {
            _logger = logger;
            _dataService = dataService;
        }

        [HttpGet()]
        public async Task<IActionResult> Get([FromQuery] string? sdate, [FromQuery] string? edate, [FromQuery] string? code, [FromQuery] string? group, [FromQuery] string? keyword)
        {
            // エンドポイントの指定
            var endpoint = _dataService.Endpoint;
            _logger.LogInformation("endpoint:{endpoint}", endpoint);

            var tokenpath = "/token";
            var schedulespath = "/schedules";
            var client = new RestClient(endpoint);

            // トークンの取得
            _logger.LogInformation("request access token.");

            var postRequest = new RestRequest(tokenpath);
            postRequest.AddParameter("username", _dataService.UserName, ParameterType.GetOrPost);
            postRequest.AddParameter("password", _dataService.Password, ParameterType.GetOrPost);

            var postResponse = await client.PostAsync(postRequest);
            if (!postResponse.IsSuccessful || postResponse.Content == null)
            {
                return BadRequest(error: "Login error.");
            }

            Token? token = JsonConvert.DeserializeObject<Token>(postResponse.Content);
            if (token == null)
            {
                return BadRequest(error: "Token error.");
            }

            // スケジュールの取得
            _logger.LogInformation("request holodules sdate:{sdate} edate:{edate} code:{code} group:{group} keyword:{keyword}.", sdate, edate, code, group, keyword);

            var getRequest = new RestRequest(schedulespath);
            getRequest.AddHeader("Content-Type", "application/json");
            getRequest.AddHeader("Authorization", $"{token.TokenType} {token.AccessToken}");
            if (!string.IsNullOrEmpty(sdate))
            {
                getRequest.AddParameter("sdate", sdate, ParameterType.GetOrPost);
            }
            if (!string.IsNullOrEmpty(edate))
            {
                getRequest.AddParameter("edate", edate, ParameterType.GetOrPost);
            }
            if (!string.IsNullOrEmpty(code))
            {
                getRequest.AddParameter("code", code, ParameterType.GetOrPost);
            }
            if (!string.IsNullOrEmpty(group))
            {
                getRequest.AddParameter("group", group, ParameterType.GetOrPost);
            }
            if (!string.IsNullOrEmpty(keyword))
            {
                getRequest.AddParameter("keyword", keyword, ParameterType.GetOrPost);
            }

            var getResponse = await client.GetAsync(getRequest);
            if (!getResponse.IsSuccessful || getResponse.Content == null)
            {
                return BadRequest(error: "Holodules error.");
            }

            // RestResponseをJSONとして返却
            return Content(getResponse.Content, "application/json");
        }
    }
}
```

## holoduler.Server\holoduler.Server.http

作成したコントローラーにあわせて HTTP リクエストファイルを構成します。

```text
# HTTPリクエストファイル
# 開発時に API エンドポイントに対する HTTP リクエストを定義し、テストやデバッグのために使用するもの
# リクエストファイルは、JSON フォーマットで記述し、拡張子は .http とする。
# リクエストファイルは、VSCode の REST Client 拡張機能でも実行できる。

@holoduler.Server_HostAddress = https://localhost:7025

### ホロジュール一覧取得 開始日＆終了日指定
GET {{holoduler.Server_HostAddress}}/api/Holodules?sdate=2024-02-09&edate=2024-02-14
Accept: application/json

### ホロジュール一覧取得 コード指定
GET {{holoduler.Server_HostAddress}}/api/Holodules?code=HL0501
Accept: application/json

### ホロジュール一覧取得 開始日＆終了日＆グループ指定
GET {{holoduler.Server_HostAddress}}/api/Holodules?sdate=2024-02-09&edate=2024-02-14&group=hololive
Accept: application/json
```

## サーバー側の動作確認

プロジェクトをデバッグ実行します。  
この時点で、Vite の開発用サーバーの起動の速さを実感できます。

holoduler.Server.http で要求を行うことで、Visual Studio 2022 で応答を確認できます。

![img02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/0df8e551-3c0b-ccc7-194d-2f09a72fcdb6.png)

もちろん、Swagger UI を利用した確認も可能です。

![img03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/199b6610-2da8-eccf-edae-3b7fe0be2ede.png)

## クライアント側の作成

クライアント側は以前開発したホロジュールの React/TypeScript Webアプリケーションに、配信者や日付の期間を指定した検索が行える機能を追加しました。

以前開発したホロジュールについては下記を参照してください。

[React v18 + TypeScript と ASP.NET Core (C#) でホロジュール Web アプリを作り直した](https://qiita.com/kerobot/items/98376b43329d8b8dd2b4)

以下、変更点をいくつか抜粋します。

### 型の定義変更

ホロサービスから取得した情報を格納するために型の定義を変更します。
配信日時と投稿日時が Date 型となり、タグが string 配列型となっています。

holoduler.client\src\types\api\schedule.ts

```typescript
// 配信情報
export type Schedule = {
    key: string;
    code: string;
    video_id: string;
    streaming_at: Date;
    name: string;
    title: string;
    url: string;
    description: string;
    published_at: Date;
    channel_id: string;
    channel_title: string;
    tags: Array<string>;
};
```

### ルーターへのパスパラメーターの追加

日付の期間を指定できるように days パスパラメーターを追加します。
結果として date と days がパスパラメーターとなり、他に keyword と group がパスクエリとなります。

holoduler.client\src\router\Router.tsx

```typescript
import { Route, Routes } from "react-router-dom";

import { HeaderLayout } from "../components/templates/HeaderLayout";
import { Holoduler } from "../components/pages/Holoduler";
import { Page404 } from "../components/pages/Page404";

// ルーターコンポーネント
export const Router = () => {
    return (
        <Routes>
            <Route path="/" element={<HeaderLayout><Holoduler /></HeaderLayout>} />
            <Route path="/:date/:days" element={<HeaderLayout><Holoduler /></HeaderLayout>} />
            <Route path="*" element={<Page404 />} />
        </Routes>
    );
};
```

### 関数や状態の更新の実行を遅らせるフックの追加

検索条件としてキーワードを追加するため、キーワードを入力して指定ミリ秒後に検索を行うフックを追加します。
useEffect 内で return を指定することでタイマーを停止し、不必要な連続検索を抑止しています。

holoduler.client\src\hooks\useDebounce.ts

```typescript
import { useEffect, useState } from "react";

type Props = {
    value: string;
    delay?: number;
};

export const useDebouncedValue = ({ value, delay = 1000 }: Props) => {
    const [debouncedValue, setDebouncedValue] = useState(value ?? "");

    // value か delay の変化により副作用を発動
    useEffect(() => {
        // delay ミリ秒経過後に value をセットして返却
        const timer = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        // 2 度目以降のレンダリング時に前回の副作用を消す
        return () => {
            clearTimeout(timer);
        };
    }, [value, delay]);

    return debouncedValue;
};
```

### 配信情報を検索するフックの修正

追加した検索条件にあわせて検索処理を修正します。
ホロサービス側の日付の扱いを Dete としたため、axios の transformResponse を使用してレスポンスデータの配信日時と投稿日時を Date 型に変換しています。

holoduler.client\src\hooks\useSchedules.ts

```typescript
import { useCallback, useState } from "react";
import axios from "axios";

import { Schedules } from "../types/api/schedules";
import { useMessage } from "./useMessage";

// Web API を呼んで配信予定を取得するカスタムフック
export const useSchedules = () => {
    const { showMessage } = useMessage();

    const [loading, setLoading] = useState(false);
    const [schedules, setSchedules] = useState<Schedules>();

    const getSchedules = useCallback((sdate: string, edate: string, group: string, keyword: string) => {
        const url = `/api/holodule?sdate=${sdate}&edate=${edate}&group=${group}&keyword=${keyword}`;
        setLoading(true);
        axiosClient
            .get<Schedules>(url)
            .then((res) => setSchedules(res.data))
            .catch((reason) => showMessage({ title: "スケジュールの取得に失敗しました", status: "error" }))
            .finally(() => setLoading(false));
    }, [showMessage]);

    return { getSchedules, loading, schedules };
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function dateParseChallenge(key: string, val: any) {
    // streaming_at か published_at は Date に変換
    if (key === "streaming_at" || key === "published_at") {
        const time = Date.parse(val);
        if (!Number.isNaN(time)) {
            return new Date(time);
        }
    }
    return val;
}

const axiosClient = axios.create({
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    transformResponse: (data: any) => {
        return JSON.parse(data, dateParseChallenge);
    }
});
```

### 配信日付表示コンポーネントへ日付期間選択機能の追加

表示する日付の期間を選択できるようにします。
chakra-ui の NumberInput コンポーネントを利用して日付の期間を選択可能とします。

holoduler.client\src\components\atoms\StreamDate.tsx

```typescript
import { FC, memo } from "react";
import { HStack, NumberInput, NumberInputField, NumberInputStepper, NumberIncrementStepper, NumberDecrementStepper, Text } from "@chakra-ui/react";
import { DateHelper } from "../../utils/DateHelper";

type Props = {
    date: Date;
    days: number;
    onChangeDays: (days: number) => void;
};

// 配信日付表示コンポーネント
export const StreamDate: FC<Props> = memo((props) => {
    const { date, days, onChangeDays } = props;
    const step = 1;
    const min = 1;
    const max = 7;

    return (
        <HStack>
            <Text>{DateHelper.formatDate(date, "/")}</Text>
            <NumberInput maxW='60px' value={days} step={step} min={min} max={max} onChange={(e) => onChangeDays(Number(e))}>
                <NumberInputField />
                <NumberInputStepper>
                    <NumberIncrementStepper />
                    <NumberDecrementStepper />
                </NumberInputStepper>
            </NumberInput>
            <Text>days</Text>
        </HStack>
    );
});
```

### キーワード検索コンポーネントの追加

配信者の名前と配信タイトルや配信内容に対してキーワード検索できるようにします。
ここで useDebouncedValue を利用することで不必要な連続検索が抑止されます。

holoduler.client\src\components\atoms\SearchBox.tsx

```typescript
import { FC, memo, useEffect, useState } from "react";
import { Input } from '@chakra-ui/react'
import { useDebouncedValue } from "../../hooks/useDebounce";

type Props = {
    keyword: string;
    onChangeKeyword: (value: string) => void;
};

// キーワード検索コンポーネント
export const SearchBox: FC<Props> = memo((props) => {
    const { keyword, onChangeKeyword } = props;
    const [inputKeyword, setInputKeyword] = useState(keyword ?? "");
    const debouncedValue = useDebouncedValue({ value: inputKeyword, delay: 1000 });

    useEffect(() => {
        onChangeKeyword(debouncedValue);
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [debouncedValue, inputKeyword]);

    return (
        <Input maxW='200px' placeholder='keyword' value={inputKeyword} onChange={
            (e) => setInputKeyword(e.target.value)
        } />
    );
});
```

### グループ選択コンポーネントの追加

配信者の所属グループを選択できるようにします。
chakra-ui の Select コンポーネントを利用しています。

holoduler.client\src\components\atoms\GroupSelect.tsx

```typescript
import { FC, memo } from "react";
import { Select } from "@chakra-ui/react";

type Props = {
    group: string;
    onChangeGroup: (group: string) => void;
};

// グループ選択コンポーネント
export const GroupSelect: FC<Props> = memo((props) => {
    const { group, onChangeGroup } = props;

    const options = [
        { value: 'all', label: 'ALL' },
        { value: 'hololive', label: 'JP' },
        { value: 'hololive_DEV_IS', label: 'DEV_IS' },
        { value: 'hololive_en', label: 'EN' },
        { value: 'hololive_id', label: 'ID' },
    ];

    return (
        <Select maxW='100px' value={group} onChange={(e) => onChangeGroup(e.target.value)} >
            {options.map((option) => (
                <option key={option.value} value={option.value}>{option.label}</option>
            ))}
        </Select>
    );
});
```

### 検索コンポーネントの修正

上記コンポネントを組み合わせて検索を指示するコンポーネントを修正します。
コンポーネントごとのイベントをここで束ねています。

holoduler.client\src\components\molecules\SearchControl.tsx

```typescript
import { FC, memo } from "react";
import { ButtonGroup, Center } from "@chakra-ui/react";

import { PrevButton } from "../atoms/PrevButton";
import { NextButton } from "../atoms/NextButton";
import { StreamDate } from "../atoms/StreamDate";
import { SearchBox } from "../atoms/SearchBox";
import { GroupSelect } from "../atoms/GroupSelect";

type Props = {
    date: Date;
    days: number;
    group: string;
    keyword: string;
    onClickPrev: () => void;
    onClickNext: () => void;
    onChangeDays: (days: number) => void
    onChangeGroup: (group: string) => void;
    onChangeKeyword: (value: string) => void;
};

// 日付移動と日付表示と検索を行うコンポーネント
export const SearchControl: FC<Props> = memo((props) => {
    const {
        date,
        days,
        group,
        keyword,
        onClickPrev,
        onClickNext,
        onChangeDays,
        onChangeGroup,
        onChangeKeyword
    } = props;

    return (
        <ButtonGroup gap='2'>
            <GroupSelect group={group} onChangeGroup={onChangeGroup} />
            <SearchBox keyword={keyword} onChangeKeyword={onChangeKeyword} />
            <PrevButton onClick={onClickPrev} />
            <Center><StreamDate date={date} days={days} onChangeDays={onChangeDays} /></Center>
            <NextButton onClick={onClickNext} />
        </ButtonGroup>
    );
});
```

### ヘッダーコンポーネントの修正

追加した検索条件に対応した処理を追加します。
イベント分の処理を記述していますがこんなもんでしょうかね。

holoduler.client\src\components\organisms\Header.tsx

```typescript
import { memo, useCallback, useState, FC } from "react";
import { useParams } from "react-router-dom";
import { Flex, Spacer } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";

import { TitleControl } from "../molecules/TitleControl";
import { SearchControl } from "../molecules/SearchControl";
import { DateHelper } from "../../utils/DateHelper";

// ヘッダーコンポーネント
export const Header: FC = memo(() => {
    const { date, days } = useParams();

    const group = new URLSearchParams(window.location.search).get("group") || "";
    const keyword = new URLSearchParams(window.location.search).get("keyword") || "";

    const [dateState, setDateState] = useState(DateHelper.stringToDate(date || DateHelper.formatDate(new Date(), "-")));
    const [daysState, setDaysState] = useState(Number(days) || 1);
    const [groupState, setGroupState] = useState(group);
    const [keywordState, setKeywordState] = useState(keyword);
    const navigate = useNavigate();

    // 指定した条件を state に保持してページ遷移
    const navigateDate = useCallback((date: Date, days: number, group: string, keyword: string) => {
        setDateState(date);
        setDaysState(days);
        setGroupState(group);
        setKeywordState(keyword);
        navigate(`/${DateHelper.formatDate(date, "-")}/${days}?group=${group}&keyword=${keyword}`);
    }, [navigate]);

    // 当日に移動
    const onClickToday = useCallback(() => {
        navigateDate(new Date(), 1, "", "");
    }, [navigateDate]);

    // 前日に移動
    const onClickPrev = useCallback(() => {
        navigateDate(DateHelper.addDays(dateState, -1), daysState, groupState, keywordState);
    }, [navigateDate, dateState, daysState, groupState, keywordState]);

    // 翌日に移動
    const onClickNext = useCallback(() => {
        navigateDate(DateHelper.addDays(dateState, 1), daysState, groupState, keywordState);
    }, [navigateDate, dateState, daysState, groupState, keywordState]);

    // 日数を変更
    const onChangeDays = useCallback((days: number) => {
        navigateDate(dateState, days, groupState, keywordState);
    }, [navigateDate, dateState, groupState, keywordState]);

    // キーワードを変更
    const onChangeKeyword = useCallback((keyword: string) => {
        navigateDate(dateState, daysState, groupState, keyword);
    }, [navigateDate, dateState, daysState, groupState]);

    // グループを変更
    const onChangeGroup = useCallback((group: string) => {
        navigateDate(dateState, daysState, group, keywordState);
    }, [navigateDate, dateState, daysState, keywordState]);

    return (
        <Flex minWidth='max-content' alignItems='center' gap='2' p='3' h="20" w="100%">
            <TitleControl onClickToday={onClickToday} />
            <Spacer />
            <SearchControl
                date={dateState}
                days={daysState}
                group={groupState}
                keyword={keywordState}
                onClickPrev={onClickPrev}
                onClickNext={onClickNext}
                onChangeDays={onChangeDays}
                onChangeGroup={onChangeGroup}
                onChangeKeyword={onChangeKeyword}
            />
        </Flex>
    );
});
```

### ホロジュールコンポーネントの修正

ヘッダーコンポーネントからの遷移に応じて検索を行い結果を表示する処理を修正します。

holoduler.client\src\components\pages\Holoduler.tsx

```typescript
import { memo, useEffect, FC, useRef } from "react";
import { useParams } from "react-router-dom";
import { Center, Spinner, Wrap, WrapItem, Text } from "@chakra-ui/react";

import { DateHelper } from "../../utils/DateHelper";
import { StreamCard } from "../organisms/StreamCard";
import { useSchedules } from "../../hooks/useSchedules";

// 配信予定ページコンポーネント
export const Holoduler: FC = memo(() => {
    const { date, days } = useParams();
    const { getSchedules, loading, schedules } = useSchedules();

    const today = new Date();
    const sdate = date || DateHelper.formatDate(today, "-");
    const addDays = Number(days) || 1;
    const edate = DateHelper.formatDate(DateHelper.addDays(DateHelper.stringToDate(sdate), addDays), "-");
    const group = new URLSearchParams(window.location.search).get("group") || "";
    const keyword = new URLSearchParams(window.location.search).get("keyword") || "";

    const didMountRef = useRef(false);

    useEffect(() => {
        // strict モード対策
        if (process.env.NODE_ENV === "development") {
            if (didMountRef.current) {
                didMountRef.current = false;
                return;
            }
        }
        getSchedules(sdate, edate, group, keyword)
    }, [getSchedules, sdate, edate, group, keyword]);

    const arr = schedules?.schedules;

    return (
        <>
            {loading ? (
                <Center h='100px' w="100%">
                    <Spinner
                        thickness='4px'
                        speed='0.65s'
                        emptyColor='gray.200'
                        color='blue.500'
                        size='xl' />
                </Center>
            ) : (
                <Wrap>
                    {
                        (arr !== undefined && arr.length > 0) ? (
                            arr.map((schedule) => (
                                <WrapItem key={schedule.key}>
                                    <StreamCard schedule={schedule} today={today} />
                                </WrapItem>
                            ))
                        ) : (
                            <Text fontSize="md" as="b">予定がありません</Text>
                        )
                    }
                </Wrap>
            )}
        </>
    );
});
```

## クライアント側の動作確認

プロジェクトをデバッグ実行します。  
Vite の開発用サーバーと React/TypeScript Webアプリケーションが起動します。

![img04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/83c1db08-4acb-14d0-747b-1e920b12b83f.png)

ホロサービスから取得した配信情報が表示され、検索条件に応じた配信情報が抽出されました。

## おわりに

最近のバージョンに合わせた Python での開発を進めた結果、ホロジュール関連の自作プログラムをすべて作り直すことになりましたが、結果として、Python を利用した Web スクレイピングや Web API (Fast API) およびモデル (Pydantic) の扱い、Visual Studio 2022 の新しい React テンプレートなどを学習することができました。

ホロジュール関連のプログラム開発が続いていますが、もうしばらく機能追加を楽しんでみようと思います。