# 【検索編】**新規**フリーワード検索フロー完全解説 (1. バックエンド層)
## はじめに:バックエンドの役割と今回のゴール
このドキュメントでは、BFFから**「このキーワードに部分一致する社員を、personalId, name, memoカラムから検索してください」**というgRPCリクエストが来たときに、バックエンド内部で何が起きるのか、その全工程を解き明かします。
### 今回のゴール:
1. フロントエンドから送られてくるフリーワードを受け取る。
2. 複数のカラム(`personalId`, `name`, `memo`)を対象に、大文字・小文字を区別しない部分一致(ILIKE)検索を行う。
3. キーワードが空、または指定されていない場合は、全ユーザーを返す。
4. 論理削除されたユーザーを除外する。
5. 検索結果として、該当するユーザーのリストを返す。
> バックエンドのフロー: gRPCリクエスト -> Controller -> Service -> Repository -> Prisma -> DB
この基本的な流れは、これまでの機能実装と全く同じです。違いは、Repository層でのPrismaのクエリの組み立て方にあります。
## Step 1: 契約の更新 - 「社員を検索する」という新しいルールを決める
まず、BFFとの通信ルール(gRPCスキーマ)を更新し、「フリーワードで社員を検索する」という新しい操作を定義します。
```proto:apps/backend/src/proto/template/user.proto
// 📍 レイヤー: Backend (Protocol Buffers)
// 📂 ファイル: apps/backend/src/proto/template/user.proto
syntax = "proto3";
package user;
import "google/protobuf/empty.proto";
// ... 既存のUser, CreateUserRequest, UpdateUserRequestなど ...
// Userのリストを返すためのレスポンスメッセージ(既存のものがあれば再利用)
message ListUsersResponse {
repeated User users = 1;
}
// --- ↓ここから追記 ---
// 「社員を検索して」というリクエストの形
message SearchUsersRequest {
// 検索キーワード。キーワードが空の場合(全件検索)も考慮し、optionalとする。
optional string keyword = 1;
}
// 既存のUserServiceに新しい機能を追加
service UserService {
// ... 既存の rpc CreateUser, UpdateUser, DeleteUser ...
// SearchUsersという名前でやり取りします、と宣言
rpc SearchUsers(SearchUsersRequest) returns (ListUsersResponse);
}
ここでの解説:
-
rpc SearchUsers(...)
: これが新しい検索機能の名前です。 -
SearchUsersRequest
: この機能が受け取る入力です。検索キーワードをkeyword
として含みます。optional
にすることで、キーワードが指定されなかった場合も、このリクエストは有効として扱われます。 -
returns (ListUsersResponse)
: 検索結果として、ユーザーの配列(repeated User
)を含むレスポンスを返すことを定義しています。
次に行うこと: このファイルを保存した後、pnpm proto-setup
を実行し、この新しいルールに対応したTypeScriptの型を、apps/backend
とapps/bff
の両方のディレクトリに自動生成します。
Step 2: リポジトリの実装 - Prismaによる複数カラム部分一致検索
Serviceから「このキーワードで検索して」と依頼を受けた際に、実際にPrismaを使ってデータベースを検索する層です。ここが今回の実装の核心部分です。
// 📍 レイヤー: Backend (Repository)
// 📂 ファイル: apps/backend/src/features/user/user.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { User as DomainUser } from './domain/User';
import { User as PrismaUser } from '@prisma/client';
@Injectable()
export class UserRepository {
constructor(private readonly prisma: PrismaService) {}
// ... 既存のcreate, update, findByIdなど ...
// --- ↓ここから追記 ---
// 解説: Serviceから、検索キーワードを受け取ります。キーワードはundefinedの可能性があります。
async search(keyword: string | undefined): Promise<DomainUser[]> {
// 1. Prisma Clientの`findMany`メソッドを使います。
const userEntities = await this.prisma.user.findMany({
// ★★★ここが、複数カラム部分一致検索の心臓部★★★
where: {
// AND条件: まず、論理削除されていないユーザーのみを対象とします。
deletedAt: null,
// AND条件: さらに、キーワードが指定されている場合は、OR検索を実行します。
// キーワードがundefinedや空文字列の場合は、このORブロックは無視され、
// 結果的に削除されていない全ユーザーが対象となります。
...(keyword && {
// `OR`を使うことで、配列で指定した条件の「いずれか」に一致するレコードを検索します。
OR: [
{
// personalIdカラムに、keywordが「含まれている」レコード
personalId: {
contains: keyword,
// `mode: 'insensitive'` を指定すると、大文字・小文字を区別しない検索(ILIKE)になります。
mode: 'insensitive',
},
},
{
// nameカラムに、keywordが「含まれている」レコード
name: {
contains: keyword,
mode: 'insensitive',
},
},
{
// memoカラムに、keywordが「含まれている」レコード
memo: {
contains: keyword,
mode: 'insensitive',
},
},
],
}),
},
// 検索結果の並び順を指定
orderBy: {
createdAt: 'desc',
},
});
// 2. 検索結果の「Prismaの型」の配列を、「Domainの型」の配列に変換して返します。
return userEntities.map(entity => this.toDomain(entity));
}
// 既存の変換メソッド
private toDomain(entity: PrismaUser): DomainUser { /* ... */ }
}
ここでの解説:
-
where: { OR: [ ... ] }
: Prismaで「A または B または C」という条件を実現するための構文です。これにより、複数のカラムを横断した検索が可能になります。 -
contains: keyword
: SQLのLIKE '%keyword%'
と同じ意味で、指定した文字列がカラムのどこかに含まれているレコードを探します。これが「部分一致検索」の正体です。 -
mode: 'insensitive'
: 多くのデータベースで、大文字と小文字を区別しない検索を実行するためのオプションです。'user'という検索語で'User'や'USER'にもヒットするようになります。(※schema.prisma
でpreviewFeatures = ["insensitiveFilters"]
の有効化が必要です) -
...(keyword && { OR: [...] })
: このJavaScriptのスプレッド構文は、keyword
が存在する場合にのみOR
条件をwhere
句に含める、という条件分岐をスマートに実現するテクニックです。これにより、「キーワードが空なら全件検索」という要件を満たしています。
Step 3: サービスの実装 - 検索ロジックの呼び出し
Controllerから依頼を受け、検索キーワードを検証し、Repositoryに検索を依頼します。
// 📍 レイヤー: Backend (Service)
// 📂 ファイル: apps/backend/src/features/user/user.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { User as DomainUser } from './domain/User';
import { SearchUsersRequest } from 'src/proto/interface/user.proto';
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
// ... 既存のcreate, update, deleteなど ...
// --- ↓ここから追記 ---
async search(request: SearchUsersRequest): Promise<DomainUser[]> {
const { keyword } = request;
// 【ビジネスロジック】
// 例えば、検索キーワードが長すぎる場合はエラーにする、などのルールをここに追加できます。
if (keyword && keyword.length > 100) {
throw new BadRequestException('検索キーワードは100文字以内で入力してください。');
}
// Repositoryに検索処理を依頼します。
return this.userRepository.search(keyword);
}
}
Step 4: コントローラーの実装 - gRPCリクエストの受付
BFFからのgRPCリクエストを最初に受け取る「窓口」です。
// 📍 レイヤー: Backend (Controller)
// 📂 ファイル: apps/backend/src/features/user/user.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { UserService } from './user.service';
import {
User as ProtoUser,
SearchUsersRequest,
ListUsersResponse
} from 'src/proto/interface/user.proto';
import { User as DomainUser } from './domain/User';
@Controller()
export class UserController {
constructor(private readonly userService: UserService) {}
// ... 既存のcreateUser, updateUser, deleteUserなど ...
// --- ↓ここから追記 ---
@GrpcMethod('UserService', 'SearchUsers')
async searchUsers(request: SearchUsersRequest): Promise<ListUsersResponse> {
// 1. 受け取ったリクエストオブジェクトを、そのままServiceに渡します。
// Serviceが、中身の`keyword`を取り出して処理してくれます。
const domainUsers = await this.userService.search(request);
// 2. Serviceから返ってきた「ドメインモデル」の配列を、
// BFFに返すための「Protoの型」の配列に変換します。
const protoUsers = domainUsers.map(user => this.domainToProto(user));
// 3. 変換後の配列を、.protoで定義したレスポンスの形(`{ users: [...] }`)に整形して返します。
return { users: protoUsers };
}
// 既存の変換関数
private domainToProto(user: DomainUser): ProtoUser { /* ... */ }
}
これで、バックエンド側の「フリーワード検索」機能の全ての土台が完成しました。BFFからgRPCリクエストが来れば、複数のカラムを対象とした部分一致検索を実行し、結果を返すことができます。
次は、このバックエンドの機能を呼び出すBFF編と、ユーザーが検索キーワードを入力するフロントエンド編に進みます。準備はよろしいでしょうか?
# 【検索編】フリーワード検索フロー完全解説 (2. BFF編)
## はじめに:BFFの「翻訳者」としての役割
このドキュメントでは、フロントエンドから**「'山田'というキーワードで社員を検索してください」**というGraphQLリクエストが来たときに、BFF内部で何が起きるのか、その全工程を解き明かします。
BFFは、フロントエンドが話す「GraphQL」という言語と、バックエンドが話す「gRPC」という言語の間に立つ、非常に優秀な**「通訳者」**です。両者の言語(プロトコル)と語彙(型)の違いを吸収し、スムーズな会話を実現します。
> BFFのフロー: GraphQLリクエスト -> Resolver -> Service -> gRPCクライアント -> Backendへ
## Step 1: GraphQLスキーマの定義 - フロントエンドとの「契約書」を作る
BFF (GraphQLスキーマ定義 - Code First)では、「Code First」アプローチをとり、TypeScriptのコード(リゾルバ)がAPI仕様の「原本」となります。`user.resolver.ts`に新しいクエリを追加します。
```typescript:apps/bff/src/features/user/user.resolver.ts
// 📍 レイヤー: BFF
// 📂 ファイル: apps/bff/src/features/user/user.resolver.ts
import { Resolver, Query, Args } from '@nestjs/graphql';
import { BffUserService } from './user.service';
import { User } from './models/user.model';
@Resolver(() => User)
export class UserResolver {
constructor(private readonly userService: BffUserService) {}
// ... 既存のuser(id: ID!)クエリなど ...
// --- ↓ここから追記 ---
// 解説: GraphQLスキーマの`Query`型に、`searchUsers`という名前の新しいクエリを追加します。
// 戻り値は、Userモデルの配列であることを示します。
@Query(() => [User], { name: 'searchUsers', description: 'キーワードでユーザーを検索します' })
async searchUsers(
// @Argsデコレータで、GraphQLクエリの引数を受け取ります。
// 'keyword'という名前で、オプショナルなString型として受け取ることを宣言します。
@Args('keyword', { type: () => String, nullable: true }) keyword?: string,
): Promise<User[]> {
// Resolverの仕事は、受け取った引数をそのままServiceに渡すことだけです。
// 実際のロジックは全てService層に委任します。
return this.userService.search(keyword);
}
}
ここでの解説:
-
@Query(() => [User], { ... })
: このメソッドが、Userの配列を返すQuery
であることを定義します。 -
@Args('keyword', ...)
: フロントエンドからsearchUsers(keyword: "...")
という形で渡される引数を受け取るための定義です。nullable: true
なので、キーワードが指定されない場合(undefined)も許容します。
次に行うこと: このファイルを保存すると、pnpm start:dev
が動いていれば、BFFが提供するschema.graphql
が自動的に更新され、searchUsers(keyword: String): [User!]!
というクエリが追加されます。
Step 2: サービスの実装 - 「プロトコル翻訳」の実行
Resolverから依頼を受け、実際にBackendへgRPCリクエストを送信する「翻訳者」です。ここがBFFのフローの核心部分です。
// 📍 レイヤー: BFF
// 📂 ファイル: apps/bff/src/features/user/user.service.ts
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
// --- 1. 必要な「型」のインポート ---
// ★ gRPCの世界で使う「言葉」を、自動生成されたファイルからインポートします。
// `as ProtoUser`のように別名を付けることで、GraphQLの型との衝突を防ぎます。
import {
UserServiceClient,
USER_SERVICE_NAME,
SearchUsersRequest,
ListUsersResponse,
User as ProtoUser,
} from 'src/generated/proto/user';
// ★ GraphQLの世界で使う「言葉」をインポートします。
import { User as GraphQLUser } from './models/user.model';
@Injectable()
export class BffUserService implements OnModuleInit {
private backendUserService!: UserServiceClient;
constructor(
// DIで、'USER_PACKAGE'という名前で登録されたgRPCクライアントを注入してもらいます。
@Inject('USER_PACKAGE') private readonly client: ClientGrpc,
) {}
// gRPCクライアントの初期化
onModuleInit() {
this.backendUserService = this.client.getService<UserServiceClient>(USER_SERVICE_NAME);
}
// ... 既存のcreate, update, findByIdなど ...
// --- ↓ここから追記 ---
// 解説: Resolverから、オプショナルな検索キーワードを受け取ります。
async search(keyword?: string): Promise<GraphQLUser[]> {
// 1. 【翻訳①:リクエスト】GraphQLの世界の引数(`keyword`)を、gRPCの世界の型(`SearchUsersRequest`)に変換します。
// これは、海外旅行に行く前に、現地の言葉で書かれた「依頼書」を作成するようなものです。
const request: SearchUsersRequest = {
keyword: keyword, // keywordがundefinedでも、protoでoptionalと定義したので問題ありません。
};
// 2. 【通信】準備しておいたgRPCクライアントを使い、Backendの`SearchUsers`メソッドを呼び出します。
// `firstValueFrom`は、非同期通信の結果をシンプルに受け取るためのおまじないです。
const response: ListUsersResponse = await firstValueFrom(
this.backendUserService.searchUsers(request),
);
// 3. バックエンドから返ってきた`ProtoUser`の配列を取り出します。
// レスポンスが空の場合も考慮して、`|| []`を付けておくとより安全です。
const protoUsers = response.users || [];
// 4. 【翻訳②:レスポンス】Backendから返ってきたgRPCの型(`ProtoUser[]`)の配列を、
// フロントエンドに返すGraphQLの型(`GraphQLUser[]`)の配列に変換します。
// これは、海外から持ち帰った品物(データ)を、国内で使えるように開封・整形する作業です。
const graphqlUsers: GraphQLUser[] = protoUsers.map(protoUser => {
// ProtoUserの各プロパティを、GraphQLUserのプロパティに一つずつマッピングします。
return {
id: protoUser.id,
personalId: protoUser.personalId, // ★personalIdを追加
name: protoUser.name,
email: protoUser.email,
memo: protoUser.memo, // ★memoを追加
// ★重要★ ここで、`belonging`などの関連オブジェクトはまだ存在しません。
// それらの解決は、UserResolverの`@ResolveField`がDataLoaderを使って行います。
// このServiceの責任は、あくまでUserの基本情報を翻訳することです。
// そのため、Resolverが後で使えるように、関連IDを渡しておきます。
belongingId: protoUser.belongingId,
teamId: protoUser.teamId,
workplaceId: protoUser.workplaceId,
};
});
// 5. 完全に翻訳された、フロントエンドが期待する形のデータ配列をResolverに返します。
return graphqlUsers;
}
}
ここでの解説:
-
翻訳①(リクエスト):
const request: SearchUsersRequest = { keyword }
の部分で、GraphQLの引数をgRPCリクエストのメッセージに変換しています。 -
gRPC呼び出し:
this.backendUserService.searchUsers(request)
で、Backendに定義したSearchUsers
RPCを正確に呼び出しています。 -
翻訳②(レスポンス):
protoUsers.map(...)
の部分で、Backend語のデータ(ProtoUser
)を、フロントエンド語のデータ(GraphQLUser
)に一つ一つ丁寧に変換しています。これにより、たとえBFFとBackendでUserのデータ構造が異なっていても、フロントエンドには常に一貫したデータを提供できます。
Step 3: モジュールとDIの確認
これらの新しい機能や依存関係が正しく動作するように、モジュールファイルを確認します。ほとんどの場合、このステップでは既存のファイルに修正は不要ですが、全体の連携を理解するために重要です。
-
bff/src/features/user/user.module.ts
:
このモジュールがUserResolver
とBffUserService
をproviders
として持ち、ClientsModule
をimports
していることを確認します。既存のUserModule
に、これらのクラスが既に含まれているはずです。 -
bff/src/grpc-client.options.ts
:
USER_PACKAGE
に対応するgRPCクライアントの定義が存在することを確認します。 -
bff/src/app.module.ts
:
ルートモジュールがUserModule
をimports
していることを確認します。
これで、BFF側の「フリーワード検索」機能の準備が完了です。BFFは、フロントエンドからのsearchUsers(keyword: "...")
というGraphQLリクエストを受け取り、Backendから取得した検索結果を返せるようになりました。
次はいよいよ最終章、フロントエンド編です。ユーザーがキーワードを入力し、検索ボタンを押す画面を実装します。準備はよろしいでしょうか?
# 【検索編】フリーワード検索フロー完全解説 (3. フロントエンド編)
## はじめに:フロントエンドの役割と今回のゴール
このドキュメントでは、ユーザーが検索ボックスにキーワードを入力し、「検索」ボタンを押してから、BFFと会話して結果を画面に表示するまでの、フロントエンド内部の全工程を解き明かします。
### 今回のゴール:
1. **初期表示**: ページを開いた時点では、`useUsers`のような全件取得用のフックを使い、全ユーザーの一覧を表示する。
2. **検索実行**: 「検索」ボタンが押されたら、`useUserSearch`フックを使い、検索結果を取得して、表示を検索結果に置き換える。
3. **コンポーネントの分離**: ロジックを持つ「親」と、UIを表示する「子」にコンポーネントを分割し、責務を明確にします。
4. **イベントの伝達**: 子コンポーネントから親コンポーネントへ、イベントを正しく伝える方法を実装します。
5. **高度なUI制御**: ローディング中や結果が0件の場合の表示を適切に行います。
> フロントエンドのフロー:GraphQLクエリ定義 -> pnpm codegen -> 型/フック自動生成 -> カスタムフックでロジックをカプセル化 -> 親コンポーネントで状態管理とイベント定義 -> 子コンポーネントでUI表示とイベント発火
## Step 1: 「欲しいものリスト」の作成 - GraphQLオペレーションを定義する
まず、BFFから**「このキーワードで社員を検索してください」**という「注文書」をGraphQLで定義します。(このステップは前回から変更ありません)
```graphql:apps/frontend/src/graphql/operations/user.graphql
# 📍 レイヤー: Frontend
# 📂 ファイル: apps/frontend/src/graphql/operations/user.graphql
# 「SearchUsers」という名前のクエリ(注文書)を定義
query SearchUsers($keyword: String) {
searchUsers(keyword: $keyword) {
id
personalId
name
memo
}
}
次に行うこと: このファイルを保存した後、pnpm --filter frontend codegen
を実行します。
Step 2: データ取得ロジックのカプセル化 - カスタムフックの作成
次に、UIコンポーネントが直接データ取得のロジックを持たないように、責務をカスタムフックに分離します。「検索ボタンが押された時だけ」クエリを実行するため、URQLのuseQuery
のpause
オプションを活用するのがポイントです。
// 📍 レイヤー: Frontend (カスタムフック)
// 📂 ファイル: apps/frontend/src/hooks/useUserSearch.ts (新規作成)
import { useQuery } from "urql";
import { SearchUsersQuery , SearchUsersQueryVariables ,SearchUsersDocument } from "../graphql/generated.graphql";
import { useState, useCallback } from "react";
export const useUserSearch = () => {
const [keyword, setKeyword] = useState<string | undefined>(undefined);
const [isPaused, setIsPaused] = useState(true);
const [result] = useQuery<SearchUsersQuery, SearchUsersQueryVariables>({
query: SearchUsersDocument,
variables: { keyword },
pause: isPaused,
});
const searchUsers = useCallback((searchKeyword: string) => {
// 検索が実行されたら、pauseを解除してクエリを有効化する
setIsPaused(false);
// 新しいキーワードをstateにセットする(これによりuseQueryが再実行される)
setKeyword(searchKeyword);
}, []);
return {
searchedUsers: result.data?.searchUsers,
isSearching: result.fetching,
searchUsers,
searchError: result.error,
};
};
ここでの解説:
このフックは、「検索を実行する」という単一の責任を持ちます。UIコンポーネントは、このフックから返されるsearchUsers
関数を呼び出すだけで、検索処理を実行できます。
Step 3: 親コンポーネントの実装 - 全てをまとめる「司令塔」
親コンポーネントは、2つのデータソース(全件取得と検索結果)を管理し、ユーザーのアクションに応じてどちらを表示するかを決定する「司令塔」の役割を担います。
// 📍 レイヤー: Frontend (ページコンポーネント)
// 📂 ファイル: apps/frontend/src/app/users/page.tsx (例)
'use client';
import { useState, useEffect } from 'react';
import { Box, Heading } from '@chakra-ui/react';
// ★2種類のデータ取得フックをインポート
import { useUsers } from '@/hooks/useUsers'; // 全件取得用(既存と仮定)
import { useUserSearch } from '@/hooks/useUserSearch';
import { SearchForm } from '@/components/SearchForm';
import { UserList } from '@/components/UserList';
import type { User } from '@/graphql/generated';
export default function UserSearchPage() {
// 1. 全件取得用のフックを呼び出す
const { users: allUsers, loading: isLoadingAll, error: allUsersError } = useUsers();
// 2. 検索用のフックを呼び出す
const { searchedUsers, isSearching, searchError, searchUsers } = useUserSearch();
// 3. ★★★ここが状態管理の核心★★★
// 実際にテーブルに表示するユーザーリストを管理するためのstate
const [displayedUsers, setDisplayedUsers] = useState<User[]>([]);
// 検索が実行されたかどうかを管理するstate
const [hasSearched, setHasSearched] = useState(false);
// 4. 【副作用フック①】初回に全件リストを表示データにセットする
useEffect(() => {
// 全件取得が完了し、まだ検索が実行されていない場合に実行
if (allUsers && !hasSearched) {
setDisplayedUsers(allUsers);
}
}, [allUsers, hasSearched]);
// 5. 【副作用フック②】検索結果を監視し、表示データを更新する
useEffect(() => {
// 検索が実行され、検索結果(たとえ0件でも)が得られた場合に実行
if (hasSearched && searchedUsers) {
setDisplayedUsers(searchedUsers);
}
}, [searchedUsers, hasSearched]);
// 6. 子(SearchForm)から「検索して!」と通知されるハンドラ
const handleSearch = (keyword: string) => {
console.log(`キーワード: "${keyword}" で検索を実行します。`);
setHasSearched(true); // 検索が実行されたことを記録
searchUsers(keyword);
};
const handleDeleteUser = (userId: string) => { /* ...削除処理... */ };
// UIに渡すローディング状態を決定(どちらかがローディング中ならtrue)
const isLoading = isLoadingAll || isSearching;
const displayError = allUsersError || searchError;
return (
<Box p={5}>
<Heading size="lg" mb={6}>社員検索</Heading>
{/* --- 7. 子コンポーネントへのデータと関数の受け渡し --- */}
<SearchForm
isLoading={isLoading}
onSearch={handleSearch}
/>
<UserList
users={displayedUsers}
isLoading={isLoading}
error={displayError}
onDelete={handleDeleteUser}
/>
</Box>
);
};
Step 4: 子コンポーネントの実装① - 検索フォーム (SearchForm.tsx)
最初に、検索インプットとボタンを持つ、再利用可能な子コンポーネントを作成します。このコンポーネントは、自分が何を探しているのかを知りません。ただ、入力されたキーワードを親に通知する責務だけを持ちます。
// 📍 レイヤー: Frontend (UIコンポーネント)
// 📂 ファイル: apps/frontend/src/components/SearchForm.tsx (新規作成)
'use client';
import { useState, ChangeEvent, KeyboardEvent } from 'react';
import { Input, Button, Stack, Icon } from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
// --- 親コンポーネントとの「契約書」となるPropsの型定義 ---
type SearchFormProps = {
// 検索処理が実行中かどうかを親から受け取る
isLoading: boolean;
// ★★★ここがイベント伝達の核心★★★
// 「検索ボタンが押された」ことを親に通知するための関数。
// 「string型のキーワードを受け取り、何も返さない関数」という形をしています。
onSearch: (keyword: string) => void;
};
export const SearchForm = ({ isLoading, onSearch }: SearchFormProps) => {
// 1. このコンポーネント自身が、入力されているテキストを管理するためのstate
const [searchTerm, setSearchTerm] = useState('');
// 2. 「検索」ボタンがクリックされたときの処理
const handleSearchClick = () => {
// 親から受け取った`onSearch`関数を呼び出します。
// 引数として、このコンポーネントが管理している現在の入力値`searchTerm`を渡します。
// これにより、親は「どのキーワードで」検索ボタンが押されたかを知ることができます。
onSearch(searchTerm);
};
// 3. Enterキーが押されたときの処理
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearchClick();
}
};
return (
<Stack direction="row" spacing={4} mb={6}>
<Input
placeholder="社員番号, 氏名, メモで検索..."
value={searchTerm}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
isDisabled={isLoading} // ローディング中は入力を無効化
/>
<Button
onClick={handleSearchClick}
colorScheme="blue"
isLoading={isLoading}
leftIcon={<Icon as={SearchIcon} />}
>
検索
</Button>
</Stack>
);
};
Step 5: 子コンポーネントの実装② - 結果リスト (UserList.tsx)
次に、親から渡された状態に基づいてUIを表示する責務を持つ、結果表示用のコンポーネントを作成します。
// 📍 レイヤー: Frontend (UIコンポーネント)
// 📂 ファイル: apps/frontend/src/components/UserList.tsx (新規作成)
'use client';
import { Table, Thead, Tbody, Tr, Th, Td, Button, Spinner, Text, Box } from '@chakra-ui/react';
import type { SearchUsersQuery } from '@/graphql/generated';
// --- 親コンポーネントとの「契約書」となるPropsの型定義 ---
type UserListProps = {
users: SearchUsersQuery['searchUsers'] | undefined | null;
isLoading: boolean;
error: any;
onDelete: (userId: string) => void; // ★親から渡される関数の型
};
export const UserList = ({ users, isLoading, error, onDelete }: UserListProps) => {
// 解説: `isLoading`, `error`, `users`の状態に応じて、
// テーブルのボディ部分に表示する内容を決定するヘルパー関数です。
const renderTableContent = () => {
// ★要件1:ローディング中の表示
// `isLoading`がtrueの場合、テーブルボディの中央にスピナーを表示します。
if (isLoading) {
return (
<Tr>
{/* colSpan={4}で、4つのカラムを結合して一つのセルとして表示します */}
<Td colSpan={4}>
<Box display="flex" justifyContent="center" p={10}>
<Spinner size="xl" color="blue.500" />
</Box>
</Td>
</Tr>
);
}
// エラーが発生した場合の表示
if (error) {
return <Tr><Td colSpan={4} color="red.500">エラーが発生しました: {error.message}</Td></Tr>;
}
// ★要件2:検索結果が0件の場合の表示
// `users`が空の配列([])の場合、`users.length === 0`がtrueになります。
if (!users || users.length === 0) {
return (
<Tr>
<Td colSpan={4} textAlign="center" p={10} color="gray.500">
データがありません
</Td>
</Tr>
);
}
// 検索結果がある場合の表示
// `users`配列をmapで展開し、各ユーザーの情報をテーブルの行として描画します。
return users.map(user => (
<Tr key={user.id}>
<Td>{user.personalId}</Td>
<Td>{user.name}</Td>
<Td>{user.memo}</Td>
<Td>
{/* ★要件4:イベントの親への伝達 */}
{/* ボタンがクリックされたら、親から受け取った`onDelete`関数を、
この行の`user.id`を引数にして呼び出す */}
<Button colorScheme="red" size="sm" onClick={() => onDelete(user.id)}>
削除
</Button>
</Td>
</Tr>
));
};
return (
<Table variant="simple">
<Thead>
<Tr>
<Th>社員番号(personalId)</Th>
<Th>氏名</Th>
<Th>メモ</Th>
<Th>操作</Th>
</Tr>
</Thead>
<Tbody>
{/* ヘルパー関数を呼び出して、テーブルの中身を描画します */}
{renderTableContent()}
</Tbody>
</Table>
);
};
ここでの解説:
-
renderTableContent
関数:isLoading
,error
,users
の状態に応じて、表示するJSXを切り替えるロジックをまとめています。これにより、メインのreturn文がスッキリし、テーブルのヘッダー(Thead)は常に表示されたままになります。 -
onDelete(user.id)
: これが**「Events Up(イベントは上に)」の実践です。子コンポーネントは、親から渡されたonDelete
関数の詳細を知りません。ただ、ボタンが押されたら、そのユーザーのIDを引数にして実行するだけ**です。実際の削除処理は、この関数を定義した親コンポーネントが責任を持って行います。
この**「状態とロジックは親に、UIとイベント発火は子に」というコンポーネント分割と、「Props Down, Events Up」**の原則を守ることで、あなたのフロントエンドアプリケーションは非常にクリーンで、再利用性が高く、メンテナンスしやすい構造になります。