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?

NextとNestで型安全な更新フロー

Last updated at Posted at 2025-06-19
# 【UPDATE編】ユーザー更新フロー完全解説 (1. バックエンド層)

## はじめに:更新処理の目的と特徴

このドキュメントでは、BFFから**「この社員の情報を、この内容で更新してください」**というgRPCリクエストが来たときに、バックエンド内部で何が起きるのか、その全工程を解き明かします。

### 今回のゴール:

* 特定のユーザーを識別し、その情報を更新する。
* 変更があった項目だけをデータベースに反映させる。
* 主キー(id)は、更新対象ではなく、識別子としてのみ使用する。

### バックエンドのフロー: gRPCリクエスト -> Controller -> Service -> Repository -> Prisma -> DB

この基本的な流れは、新規登録の時と全く同じです。違いは、各層が扱う「データの形」と「処理の内容」にあります。

---

## Step 1: 契約の更新 - 「社員を更新する」という新しいルールを決める

まず、BFFとの通信ルール(gRPCスキーマ)を更新し、「更新」という新しい操作を定義します。

* **レイヤー**: Backend (gRPC契約定義)
* **ファイル**: `apps/backend/src/proto/template/user.proto`

```protobuf
// 📍 レイヤー: Backend (Protocol Buffers)
// 📂 ファイル: apps/backend/src/proto/template/user.proto

syntax = "proto3";
package user;

// ... 既存のUserメッセージやCreateUserRequestなど ...

// --- ↓ここから追記 ---

// 「社員を更新して」というリクエストの形
message UpdateUserRequest {
  // ★★★ 最重要 ★★★
  // どのユーザーを更新するのかを特定するためのID。これは必須項目。
  string id = 1;

  // --- ▼ 更新される可能性のあるフィールド ▼ ---
  // `optional`キーワードを付けることで、これらのフィールドが
  // リクエストに含まれていなくても良いことを示します。
  // これが「変更があった項目だけを送る」を実現するための鍵です。
  optional string employeeNumber = 2;
  optional string name = 3;
  optional string email = 4;
  optional string belongingId = 5;
  optional string teamId = 6;
  optional string workplaceId = 7;
}

// 既存のUserServiceに新しい機能を追加
service UserService {
  // ... 既存の rpc CreateUser ...

  // UpdateUserという名前でやり取りします、と宣言
  // 更新された後のUser情報をレスポンスとして返す
  rpc UpdateUser(UpdateUserRequest) returns (User);
}

ここでの解説:

  • string id = 1;: 主キーは、どのユーザーを更新するのかを特定するために必須です。
  • optionalキーワード: これが更新処理の核心です。フロントエンドは変更があったフィールドだけをBFFに送り、BFFはそれだけをBackendに送ります。optionalを付けておくことで、リクエストにnameが含まれていなくても、このUpdateUserRequestは有効なリクエストとして扱われます。

次に行うこと: このファイルを保存した後、pnpm proto-setupを実行し、この新しいUpdateUserRequestに対応したTypeScriptの型を自動生成します。


Step 2: リポジトリの実装 - データベースへの部分更新

  • レイヤー: Backend
  • ファイル: apps/backend/src/features/user/user.repository.ts

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, Category } from '@prisma/client'; // Prismaの関連モデルもインポート

// Repositoryがupdateメソッドで受け取る引数の型を定義
// 更新データはオプショナルなので、Partial<T>ユーティリティ型を使うと便利
type UpdateUserParams = {
  id: string; // どのユーザーを更新するか
  data: Partial<{ // 更新するデータ(全て省略可能)
    employeeNumber: string;
    name: string;
    email: string;
    belongingId: string;
    teamId: string;
    workplaceId: string;
  }>
};

@Injectable()
export class UserRepository {
  constructor(private readonly prisma: PrismaService) {}

  // ... 既存のcreate, findByEmailなど ...

  // --- ↓ここから追記 ---

  // 解説: Serviceから、更新対象のIDと、更新するデータを受け取ります。
  async update(params: UpdateUserParams): Promise<DomainUser> {
    const { id, data } = params;
    
    // 1. Prisma Clientの`update`メソッドを使います。
    const updatedUserEntity = await this.prisma.user.update({
      // ★★★ `where`句で、更新対象のレコードを一意に特定します ★★★
      // 主キーは、ここで使われます。
      where: { id: id },

      // ★★★ `data`句に、更新したい内容だけを渡します ★★★
      // `data`オブジェクトに渡されなかったフィールドは、一切変更されません。
      // これが「変更があった項目だけを更新する」を実現するPrismaの機能です。
      data: {
        employeeNumber: data.employeeNumber,
        name: data.name,
        email: data.email,
        // リレーションの更新も、`connect`を使えばIDを指定するだけで可能です。
        // dataにIDが含まれている場合のみ、connect処理を行います。
        belonging: data.belongingId ? { connect: { id: data.belongingId } } : undefined,
        team: data.teamId ? { connect: { id: data.teamId } } : undefined,
        workplace: data.workplaceId ? { connect: { id: data.workplaceId } } : undefined,
      },
      // 更新後の完全なデータを取得するために、関連データもincludeします。
      include: {
        belonging: true,
        team: true,
        workplace: true,
      }
    });

    // 2. 更新後の「Prismaの型」を「Domainの型」に変換してServiceに返します。
    return this.toDomain(updatedUserEntity);
  }

  // toDomainメソッドも、リレーションを扱えるように修正(または確認)
  private toDomain(entity: PrismaUser & { belonging: Belonging, team: Team, workplace: Workplace }): DomainUser {
    return new DomainUser({
      id: entity.id,
      email: entity.email,
      name: entity.name,
      employeeNumber: entity.employeeNumber,
      belongingId: entity.belongingId,
      teamId: entity.teamId,
      workplaceId: entity.workplaceId,
      // ... 必要であれば、関連オブジェクトそのものもドメインモデルに持たせる ...
    });
  }
}

ここでの解説:

  • prisma.user.update(): このメソッドが更新処理の全てを担います。whereで対象を絞り、dataで変更内容を伝える、という非常に直感的な使い方です。
  • 主キーの安全性: idwhere句でのみ使われ、data句には含まれていません。これにより、主キーそのものが誤って更新されることを防ぎます。

Step 3: サービスの実装 - 更新時のビジネスルールを実行

  • レイヤー: Backend
  • ファイル: apps/backend/src/features/user/user.service.ts

Controllerから依頼を受け、更新に際してのビジネスルールを適用します。

// 📍 レイヤー: Backend (Service)
// 📂 ファイル: apps/backend/src/features/user/user.service.ts

import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { User as DomainUser } from './domain/User';

// Serviceが受け取る更新データの形を定義
type UpdateUserArgs = {
  id: string;
  employeeNumber?: string;
  name?: string;
  email?: string;
  belongingId?: string;
  teamId?: string;
  workplaceId?: string;
};

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  // ... 既存のcreateなど ...

  // --- ↓ここから追記 ---

  async update(args: UpdateUserArgs): Promise<DomainUser> {
    const { id, ...updateData } = args;

    // 【ビジネスロジック①】そもそも、更新対象のユーザーが存在するかチェック
    const existingUser = await this.userRepository.findById(id);
    if (!existingUser) {
      throw new NotFoundException(`ID: ${id} のユーザーは見つかりませんでした。`);
    }

    // 【ビジネスロジック②】もしemailが変更される場合、新しいemailが他で使われていないかチェック
    if (updateData.email && updateData.email !== existingUser.email) {
      const userWithNewEmail = await this.userRepository.findByEmail(updateData.email);
      if (userWithNewEmail && userWithNewEmail.id !== id) {
        throw new ConflictException('そのメールアドレスは既に使用されています。');
      }
    }
    
    // (同様に、社員番号の重複チェックなどもここで行う)

    // 全てのビジネスルールをクリアしたら、Repositoryに更新処理を依頼します。
    return this.userRepository.update({ id, data: updateData });
  }
}

Step 4: コントローラーの実装 - gRPCリクエストの受付

  • レイヤー: Backend
  • ファイル: apps/backend/src/features/user/user.controller.ts

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, UpdateUserRequest } from 'src/proto/interface/user.proto';
import { User as DomainUser } from './domain/User';

@Controller()
export class UserController {
  constructor(private readonly userService: UserService) {}

  // ... 既存のcreateUserなど ...

  // --- ↓ここから追記 ---

  @GrpcMethod('UserService', 'UpdateUser')
  // フロントでバリデーション済みなので、DTOは使わずProtoの型を直接受け取ります。
  async updateUser(request: UpdateUserRequest): Promise<ProtoUser> {
    // 1. 受け取ったリクエストデータをそのままServiceに渡します。
    //    `request`には`id`と、変更のあったオプショナルなフィールドが含まれています。
    const updatedUserDomain = await this.userService.update(request);

    // 2. Serviceから返ってきた更新後の「ドメインモデル」を、
    //    BFFに返すための「Protoの型」に変換します。
    return this.domainToProto(updatedUserDomain);
  }

  // 既存の変換関数
  private domainToProto(user: DomainUser): ProtoUser {
    // ... マッピング処理 ...
  }
}

これで、バックエンド側の「社員情報更新」機能の全ての土台が完成しました。BFFからgRPCリクエストが来れば、存在チェック、内容のビジネス検証、データベースの部分更新まで、一気通貫で行うことができます。



【UPDATE編】ユーザー更新フロー完全解説 (2. BFF編)

はじめに:BFFの「翻訳者」としての役割

このドキュメントでは、フロントエンドから**「IDが'user-123'の社員の、名前を'山田 花子'に変更してください」**というGraphQL Mutationリクエストが来たときに、BFF内部で何が起きるのか、その全工程を解き明かします。

BFFは、フロントエンドが話す「GraphQL」という言語と、バックエンドが話す「gRPC」という言語の間に立つ、非常に優秀な**「通訳者」**です。両者の言語(プロトコル)と語彙(型)の違いを吸収し、スムーズな会話を実現します。

BFFのフロー: GraphQL Mutation -> Resolver -> Service -> gRPCクライアント -> Backendへ


Step 1: GraphQLスキーマの定義 - フロントエンドとの「契約書」を作る

  • レイヤー: BFF (GraphQLスキーマ定義 - Code First)
  • ファイル: bff/src/features/user/models/user.model.ts

「Code First」アプローチでは、TypeScriptのクラスとデコレータを使ってAPIの仕様を定義します。これがGraphQLスキーマの「原本」になります。updateUserミューテーションが受け取る引数の形を、新しいInputTypeとして定義します。

// 📍 レイヤー: BFF
// 📂 ファイル: bff/src/features/user/models/user.model.ts

import { ObjectType, Field, ID, InputType } from '@nestjs/graphql';

// --- 既存のUser型 (フロントエンドに返すデータの形) ---
@ObjectType({ description: 'ユーザー情報を表すモデル' })
export class User {
  @Field(() => ID)
  id!: string;

  @Field()
  name!: string;

  @Field()
  email!: string;

  @Field()
  employeeNumber!: string;
}

// --- ↓ここから追記 ---

// 解説: 「ユーザーを更新する」という操作の入力データの形を定義します。
//       @InputTypeデコレータが、これがGraphQLの`input UpdateUserInput`であることを示します。
@InputType()
export class UpdateUserInput {
  // ★★★ 最重要 ★★★
  // どのユーザーを更新するのかを特定するためのID。これは必須です。
  @Field(() => ID)
  id!: string;

  // --- ▼ 更新される可能性のあるフィールド ▼ ---
  // `nullable: true` を設定することで、これらのフィールドがオプショナル(任意)であることを示します。
  // これにより、フロントエンドは変更があった項目だけを送信できます。
  @Field({ nullable: true })
  employeeNumber?: string;

  @Field({ nullable: true })
  name?: string;

  @Field({ nullable: true })
  email?: string;

  @Field(() => ID, { nullable: true })
  belongingId?: string;
  
  @Field(() => ID, { nullable: true })
  teamId?: string;

  @Field(() => ID, { nullable: true })
  workplaceId?: string;
}

ここでの解説:

  • @InputType(): このクラスが、GraphQLのMutationで使われるinput型であることを示します。
  • nullable: true: フィールドを任意項目にします。これにより、GraphQLスキーマではname: Stringのように!が付かない形になり、部分的な更新が可能になります。

次に行うこと: このファイル(と次のリゾルバファイル)を保存すると、pnpm start:devが動いていれば、BFFが提供するschema.graphqlが自動的に更新され、updateUser(input: UpdateUserInput!): User!というミューテーションが追加されます。


Step 2: リゾルバの実装 - GraphQLリクエストの「受付窓口」

  • レイヤー: BFF
  • ファイル: bff/src/features/user/user.resolver.ts

フロントエンドからupdateUserミューテーションが来たときに、それを最初に受け取るのがResolverです。

// 📍 レイヤー: BFF
// 📂 ファイル: bff/src/features/user/user.resolver.ts

import { Resolver, Mutation, Args } from '@nestjs/graphql';
import { BffUserService } from './user.service';
import { User, UpdateUserInput } from './models/user.model'; // Step 1で定義したクラス

@Resolver(() => User)
export class UserResolver {
  constructor(
    private readonly userService: BffUserService,
  ) {}

  // ... 既存のcreateUserクエリなど ...

  // --- ↓ここから追記 ---

  // 解説: GraphQLスキーマの`Mutation`の`updateUser`フィールドと、
  // このメソッドを紐づけます。
  @Mutation(() => User, { name: 'updateUser' })
  async updateUser(
    // @Argsデコレータで、GraphQLのinput全体を受け取ります。
    // 'input'という名前で、`UpdateUserInput`という形の引数を受け取ることを宣言します。
    @Args('input') input: UpdateUserInput,
  ): Promise<User> {
    // Resolverの仕事は、リクエストを適切なServiceに渡すことだけです。
    // `input`には、`id`と、変更があったフィールドだけが含まれています。
    return this.userService.update(input);
  }
}

ここでの解説:

  • @Mutation()デコレータが、このメソッドを書き込み処理の担当としてNestJSに登録します。
  • @Args('input')で、フロントエンドから送られてきた更新データ一式をinputという引数で受け取っています。

Step 3: サービスの実装 - 「プロトコル翻訳」の実行

  • レイヤー: BFF
  • ファイル: bff/src/features/user/user.service.ts

Resolverから依頼を受け、実際にBackendへユーザー更新のgRPCリクエストを送信する「翻訳者」です。

// 📍 レイヤー: BFF
// 📂 ファイル: bff/src/features/user/user.service.ts

import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
// ★ gRPCの型をインポート
import { UserServiceClient, USER_SERVICE_NAME, UpdateUserRequest, User as ProtoUser } from 'src/generated/proto/user';
// ★ GraphQLの型をインポート
import { UpdateUserInput, User as GraphQLUser } from './models/user.model';

@Injectable()
export class BffUserService implements OnModuleInit {
  private backendUserService!: UserServiceClient;

  constructor(
    @Inject(USER_PACKAGE_NAME) private readonly client: ClientGrpc,
  ) {}

  onModuleInit() {
    this.backendUserService = this.client.getService<UserServiceClient>(USER_SERVICE_NAME);
  }

  // ... 既存のcreateメソッド ...

  // --- ↓ここから追記 ---

  // 解説: Resolverから、更新対象のIDと、更新するデータを受け取ります。
  async update(input: UpdateUserInput): Promise<GraphQLUser> {
    // 1. 【翻訳①】GraphQLの世界の型(`UpdateUserInput`)を、gRPCの世界の型(`UpdateUserRequest`)に変換します。
    //    `input`には変更のないフィールドは含まれていない可能性がありますが、そのまま渡せばOKです。
    //    .protoで`optional`と定義したおかげで、gRPC側は問題なく解釈してくれます。
    const request: UpdateUserRequest = {
      id: input.id,
      name: input.name,
      email: input.email,
      employeeNumber: input.employeeNumber,
      belongingId: input.belongingId,
      teamId: input.teamId,
      workplaceId: input.workplaceId,
    };

    // 2. 準備しておいたgRPCクライアントを使い、Backendの`UpdateUser`メソッドを呼び出します。
    const response: ProtoUser = await firstValueFrom(
        this.backendUserService.updateUser(request)
    );

    // 3. 【翻訳②】Backendから返ってきた更新後のgRPCの型(`ProtoUser`)を、フロントエンドに返すGraphQLの型(`GraphQLUser`)に変換します。
    //    このマッピングにより、BFFとBackendのデータ構造が違っても、フロントエンドには影響を与えません。
    return {
      id: response.id,
      name: response.name,
      email: response.email,
      employeeNumber: response.employeeNumber,
    };
  }
}

ここでの解説:

このサービスは、フロントエンドの世界の言葉(GraphQLのUpdateUserInput)と、バックエンドの世界の言葉(gRPCのUpdateUserRequest)の両方を知っており、その間を正確に翻訳しています。行きと帰りの両方で「翻訳」が発生するのがポイントです。


Step 4: モジュールとDIの確認

これらの新しい機能や依存関係が正しく動作するように、モジュールファイルを確認します。user.module.tsapp.module.tsに変更は不要ですが、これらのファイルがUserResolverBffUserServiceを正しく登録し、ClientsModuleをインポートしていることが、このフロー全体の前提となります。

これで、BFF側の「ユーザー情報更新」フローの準備が完了です。BFFは、フロントエンドからのGraphQL Mutationリクエストを受け取り、それをBackendへのgRPCリクエストに変換して、処理を依頼し、結果をフロントエンドに返すことができるようになりました。



【UPDATE編】ユーザー更新フロー完全解説 (3. フロントエンド編)

はじめに:フロントエンドの役割とゴール

このドキュメントでは、ユーザーが「編集」ボタンを押し、既存の情報が入力されたフォームが表示され、内容を変更して「更新」ボタンを押すまでの、フロントエンド内部の全工程を解き明かします。

今回のゴール:

  • データ表示: 親コンポーネントから渡されたユーザー情報を、フォームの初期値として表示する。
  • 差分検出: ユーザーが変更した項目だけを特定する。
  • 部分更新: 変更があった項目と、更新対象のIDだけをBFFに送信する。
  • 堅牢なUI: バリデーションと、その結果に応じたボタンの非活性化を実装する。

フロントエンドのフロー:

GraphQL Mutation定義 -> pnpm codegen -> 型/フック自動生成 -> UIコンポーネント実装 (差分検出・バリデーション) -> BFFへ送信


Step 1: 「やりたいことリスト」の作成 - GraphQL Mutationを定義する

まず、BFFと会話するための「ユーザー更新」の注文書を準備します。

  • レイヤー: Frontend (GraphQL定義)
  • ファイル: apps/frontend/src/graphql/operations/user.graphql (追記)
# 📍 レイヤー: Frontend
# 📂 ファイル: apps/frontend/src/graphql/operations/user.graphql

# ... 既存の GetUserById クエリなど ...

# --- ↓ここから追記 ---

# 「UpdateUser」という名前のミューテーション(注文書)を定義
# このミューテーションは、必須のUpdateUserInputを引数として受け取ります。
mutation UpdateUser($input: UpdateUserInput!) {
  # BFFの`updateUser(input: ...)`というAPIを呼び出してください
  updateUser(input: $input) {
    # 更新が成功したら、返してほしいユーザーのフィールドはこれだけです
    id
    name
    email
    employeeNumber
  }
}

次に行うこと: このファイルを保存した後、pnpm --filter frontend codegenを実行します。これにより、次のステップで使うuseUpdateUserMutationフックがgenerated.tsに自動生成されます。


Step 2: データ操作ロジックのカプセル化 - カスタムフックの作成

次に、UIコンポーネントが直接データ更新のロジックを持たないように、責務をカスタムフックに分離します。

  • レイヤー: Frontend (カスタムフック)
  • ファイル: apps/frontend/src/hooks/useUpdateUser.ts (新規作成)
// 📍 レイヤー: Frontend
// 📂 ファイル: apps/frontend/src/hooks/useUpdateUser.ts

import { useMutation } from 'urql';
// Step 1の`pnpm codegen`で自動生成されたコードと型をインポート
import {
  UpdateUserMutation,
  UpdateUserMutationVariables,
  UpdateUserDocument,
} from '@/graphql/generated';

export const useUpdateUser = () => {
  const [mutationResult, executeMutation] = useMutation<
    UpdateUserMutation,
    UpdateUserMutationVariables
  >(UpdateUserDocument);

  const updateUser = async (input: UpdateUserMutationVariables['input']) => {
    const result = await executeMutation({ input });

    if (result.error) {
      console.error('ユーザー更新に失敗しました: ', result.error);
      throw result.error;
    }
    return result.data?.updateUser;
  };

  return {
    updateUser,
    updating: mutationResult.fetching, // 更新中かどうかのフラグ
  };
};

Step 3: UIコンポーネントの実装 - 差分検出とバリデーションを持つフォーム

いよいよ最終段階です。これが「更新フォーム」の完全な実装です。
親コンポーネントから渡された「更新前のユーザー情報」と、ユーザーが入力した「現在のフォーム情報」を比較し、変更があった項目だけを送信するロジックを実装します。

  • レイヤー: Frontend (UIコンポーネント)
  • ファイル: apps/frontend/src/components/EditUserDrawer.tsx
// 📍 レイヤー: Frontend
// 📂 ファイル: apps/frontend/src/components/EditUserDrawer.tsx

'use client';

import { useState, useEffect, useRef, ChangeEvent, FormEvent } from 'react';
import {
  Drawer, DrawerBody, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerContent, DrawerCloseButton,
  Button, Stack, Box, FormLabel, Input, Select, useToast, FormControl, FormErrorMessage, Spinner
} from '@chakra-ui/react';
import { useUpdateUser } from '@/hooks/useUpdateUser';
import { useFormOptions } from '@/hooks/useFormOptions';
import type { GetUserByIdQuery } from '@/graphql/generated';

// --- 1. 親コンポーネントとの「契約書」となるPropsの型定義 ---
type UserData = GetUserByIdQuery['user']; // 取得するユーザーデータの型
type EditUserDrawerProps = {
  isOpen: boolean;
  onClose: () => void;
  userToEdit: UserData | null; // ★編集対象のユーザー情報を親から受け取る
  onSuccess: () => void;
};

// フォームの入力値の型
type FormState = {
  employeeNumber: string;
  name: string;
  email: string;
  belongingId: string;
  teamId: string;
  workplaceId: string;
};

export const EditUserDrawer = ({ isOpen, onClose, userToEdit, onSuccess }: EditUserDrawerProps) => {
  // --- 2. 必要なフックと状態を呼び出す ---
  const { options, loading: optionsLoading } = useFormOptions();
  const { updateUser, updating } = useUpdateUser();
  const toast = useToast();

  const [formData, setFormData] = useState<FormState>({ /* ...初期値... */ });
  const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});

  // --- 3. ★★★データ表示の核心★★★ ---
  // `useEffect`を使って、親から渡される`userToEdit`の変更を監視します。
  useEffect(() => {
    // 編集対象のユーザー情報(`userToEdit`)が存在する場合にのみ実行
    if (userToEdit) {
      // 取得したデータで、フォームの状態を更新(初期値をセット)する
      setFormData({
        employeeNumber: userToEdit.employeeNumber || '',
        name: userToEdit.name || '',
        email: userToEdit.email || '',
        belongingId: userToEdit.belonging?.id || '',
        teamId: userToEdit.team?.id || '',
        workplaceId: userToEdit.workplace?.id || '',
      });
      // Drawerが開いた時はエラーをリセット
      setErrors({});
    }
  }, [userToEdit]); // `userToEdit`が変化した時(新しいユーザーが選択された時)だけ、この処理が走ります

  // --- 4. バリデーションとイベントハンドラ ---
  const validateField = (name: keyof FormState, value: string): string => { /* ...バリデーションロジック... */ return ''; };
  const handleInputChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
    const { name, value } = e.target as { name: keyof FormState; value: string };
    setFormData(prev => ({ ...prev, [name]: value }));
    const error = validateField(name, value);
    setErrors(prev => ({ ...prev, [name]: error }));
  };

  // --- 5. ★★★更新処理の核心★★★ ---
  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    if (!userToEdit) return; // 更新対象がいなければ何もしない

    // 5a. 【差分検出】元のデータと現在のフォームデータを比較し、変更があった項目だけを抽出
    const changes: Partial<FormState> = {};
    for (const key in formData) {
      const formValue = formData[key as keyof FormState];
      // 比較のために、元のデータも同じ形に整形する
      const originalValue = {
        employeeNumber: userToEdit.employeeNumber,
        name: userToEdit.name,
        email: userToEdit.email,
        belongingId: userToEdit.belonging?.id,
        teamId: userToEdit.team?.id,
        workplaceId: userToEdit.workplace?.id,
      }[key as keyof FormState];
      
      if (formValue !== originalValue) {
        changes[key as keyof FormState] = formValue;
      }
    }

    if (Object.keys(changes).length === 0) {
      toast({ title: '変更点がありません。', status: 'info' });
      return;
    }
    
    // 5b. 【ペイロード作成】更新APIに送るデータを作成(IDは必須)
    const payload = {
      id: userToEdit.id, // ★主キー(personalId)は必ず含める
      ...changes,
    };
    
    try {
      // 5c. 【API実行】変更点だけを含むペイロードを送信
      await updateUser(payload);
      toast({ title: 'ユーザー情報を更新しました。', status: 'success' });
      onSuccess(); // 親に成功を通知(一覧の再取得などを促す)
      onClose();   // Drawerを閉じる
    } catch (error) {
      toast({ title: '更新に失敗しました。', description: error.message, status: 'error' });
    }
  };
  
  // 5d. 【ボタン非活性判定】
  const isFormValid = Object.keys(formData).every(key => validateField(key as keyof FormState, formData[key as keyof FormState]) === '');

  // --- 6. UIのレンダリング ---
  return (
    <Drawer isOpen={isOpen} placement="right" onClose={onClose}>
      <DrawerOverlay />
      <form onSubmit={handleSubmit}>
        <DrawerContent>
          <DrawerCloseButton />
          <DrawerHeader>社員情報の編集</DrawerHeader>
          <DrawerBody>
            {!userToEdit ? <Spinner /> : (
              <Stack spacing="24px">
                {/* ...各FormControl (valueとonChangeをformDataに紐付ける)... */}
              </Stack>
            )}
          </DrawerBody>
          <DrawerFooter>
            <Button variant="outline" mr={3} onClick={onClose}>キャンセル</Button>
            <Button colorScheme="blue" type="submit" isLoading={updating} isDisabled={!isFormValid || updating}>更新</Button>
          </DrawerFooter>
        </DrawerContent>
      </form>
    </Drawer>
  );
};

フロントのイベント受け渡し


'use client';
//メインページ

import Link from 'next/link';
import { useState } from 'react';


// 🔥 型安全な自動生成されたhooksを使用
import { 
  useGetProductsWithCategoryQuery,
  useGetCategoriesQuery,
  useGetUsersQuery 
} from '@/graphql/generated.graphql';

// 新しく作成したコンポーネント
import { ProductTable } from '@/components/admin/ProductTable';
import { UpdateProductDrawer } from '@/components/admin/UpdateProductDrawer';

export default function AdminPage() {
  const [activeTab, setActiveTab] = useState<'products' | 'categories' | 'users'>('products');
  
  // 商品型定義
  type Product = {
    id: string;
    name: string;
    description?: string | null;
    price: number;
    stock: number;
    categoryId: string;
    category?: {
      id: string;
      name: string;
    };
  };

  // Drawer制御のためのstate
  const [isOpen, setIsOpen] = useState(false);
  const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);

  // 編集ボタンクリック時のハンドラー
  const handleEdit = (product: Product) => {
    console.log('🎯 AdminPage: handleEditが呼び出されました', product);
    console.log('🎯 AdminPage: Drawerを開きます', { isOpen, selectedProduct });
    setSelectedProduct(product);
    setIsOpen(true);
  };

  // Drawer閉じるハンドラー
  const handleClose = () => {
    setIsOpen(false);
    setSelectedProduct(null);
  };

  // 🎉 型安全なGraphQLクエリの実行
  const [productsResult] = useGetProductsWithCategoryQuery();
  const [categoriesResult] = useGetCategoriesQuery();
  const [usersResult] = useGetUsersQuery();

  // 🔍 実際のスキーマに合わせたデータ取得
  const products = productsResult.data?.listProducts || [];
  const categories = categoriesResult.data?.listCategories || [];
  const users = usersResult.data?.listUsers || [];

  // ローディング状態
  const isLoading = productsResult.fetching || categoriesResult.fetching || usersResult.fetching;

  // エラー状態
  const hasError = productsResult.error || categoriesResult.error || usersResult.error;

  // エラー表示
  if (hasError) {
    return (
      <div className="min-h-screen bg-gray-50 flex items-center justify-center">
        <div className="text-center">
          <h2 className="text-2xl font-bold text-red-600 mb-4">エラーが発生しました</h2>
          <p className="text-gray-600 mb-4">
            GraphQLサーバーに接続できません。BFFサーバーが起動していることを確認してください。
          </p>
          <div className="text-sm text-gray-500 bg-gray-100 p-4 rounded-lg">
            <p>エラー詳細:</p>
            <p>{productsResult.error?.message || categoriesResult.error?.message || usersResult.error?.message}</p>
          </div>
          <button 
            onClick={() => window.location.reload()} 
            className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
          >
            再読み込み
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50">
      {/* Header */}
      <header className="bg-white shadow-sm border-b">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
          <div className="flex items-center justify-between h-16">
            <div className="flex items-center space-x-4">
              <Link href="/" className="text-blue-600 hover:text-blue-800">
                ← ECサイトに戻る
              </Link>
              <div className="h-6 border-l border-gray-300"></div>
              <h1 className="text-2xl font-bold text-gray-900">管理画面</h1>
            </div>
            <div className="text-sm text-gray-500">
              GraphQL + gRPC + DataLoader システム
            </div>
          </div>
        </div>
      </header>

      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        {/* ローディング表示 */}
        {isLoading && (
          <div className="flex items-center justify-center py-8">
            <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
            <span className="ml-2 text-gray-600">データを読み込み中...</span>
          </div>
        )}

        {/* 統計カード */}
        <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
          <div className="bg-white rounded-lg shadow p-6">
            <div className="flex items-center">
              <div className="p-3 bg-blue-100 rounded-lg">
                <svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
                </svg>
              </div>
              <div className="ml-4">
                <p className="text-sm font-medium text-gray-600">総商品数</p>
                <p className="text-2xl font-bold text-gray-900">
                  {products.length}
                </p>
              </div>
            </div>
          </div>

          <div className="bg-white rounded-lg shadow p-6">
            <div className="flex items-center">
              <div className="p-3 bg-green-100 rounded-lg">
                <svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
                </svg>
              </div>
              <div className="ml-4">
                <p className="text-sm font-medium text-gray-600">カテゴリ数</p>
                <p className="text-2xl font-bold text-gray-900">
                  {categories.length}
                </p>
              </div>
            </div>
          </div>

          <div className="bg-white rounded-lg shadow p-6">
            <div className="flex items-center">
              <div className="p-3 bg-purple-100 rounded-lg">
                <svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0z" />
                </svg>
              </div>
              <div className="ml-4">
                <p className="text-sm font-medium text-gray-600">ユーザー数</p>
                <p className="text-2xl font-bold text-gray-900">
                  {users.length}
                </p>
              </div>
            </div>
          </div>
        </div>

        {/* タブナビゲーション */}
        <div className="bg-white rounded-lg shadow">
          <div className="border-b border-gray-200">
            <nav className="flex space-x-8 px-6">
              <button
                onClick={() => setActiveTab('products')}
                className={`py-4 px-1 border-b-2 font-medium text-sm ${
                  activeTab === 'products'
                    ? 'border-blue-500 text-blue-600'
                    : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
                }`}
              >
                商品管理
              </button>
              <button
                onClick={() => setActiveTab('categories')}
                className={`py-4 px-1 border-b-2 font-medium text-sm ${
                  activeTab === 'categories'
                    ? 'border-blue-500 text-blue-600'
                    : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
                }`}
              >
                カテゴリ管理
              </button>
              <button
                onClick={() => setActiveTab('users')}
                className={`py-4 px-1 border-b-2 font-medium text-sm ${
                  activeTab === 'users'
                    ? 'border-blue-500 text-blue-600'
                    : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
                }`}
              >
                ユーザー管理
              </button>
            </nav>
          </div>

          {/* タブコンテンツ */}
          <div className="p-6">
            {activeTab === 'products' && (
              <div>
                <div className="flex justify-between items-center mb-6">
                  <h3 className="text-lg font-medium text-gray-900">商品一覧</h3>
                  <Link
                    href="/admin/products/new"
                    className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
                  >
                    新規商品追加
                  </Link>
                </div>
                <ProductTable 
                  products={products} 
                  onEdit={handleEdit}
                  isLoading={isLoading}
                />
              </div>
            )}

            {activeTab === 'categories' && (
              <div>
                <div className="flex justify-between items-center mb-6">
                  <h3 className="text-lg font-medium text-gray-900">カテゴリ一覧</h3>
                  <button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
                    新規カテゴリ追加
                  </button>
                </div>
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                  {categories.map((category) => (
                    <div key={category.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
                      <h4 className="font-medium text-gray-900 mb-2">{category.name}</h4>
                      <p className="text-sm text-gray-600 mb-4">{category.description || '説明なし'}</p>
                      <div className="flex justify-between items-center">
                        <div className="text-xs text-gray-500">
                          ID: {category.id}
                        </div>
                        <div className="flex space-x-2">
                          <button className="text-blue-600 hover:text-blue-900 text-sm">編集</button>
                          <button className="text-red-600 hover:text-red-900 text-sm">削除</button>
                        </div>
                      </div>
                    </div>
                  ))}
                  {categories.length === 0 && !isLoading && (
                    <div className="col-span-full text-center py-8 text-gray-500">
                      カテゴリが見つかりません
                    </div>
                  )}
                </div>
              </div>
            )}

            {activeTab === 'users' && (
              <div>
                <div className="flex justify-between items-center mb-6">
                  <h3 className="text-lg font-medium text-gray-900">ユーザー一覧</h3>
                  <button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
                    新規ユーザー追加
                  </button>
                </div>
                <div className="overflow-x-auto">
                  <table className="min-w-full divide-y divide-gray-200">
                    <thead className="bg-gray-50">
                      <tr>
                        <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ユーザー</th>
                        <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">メールアドレス</th>
                        <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">登録日</th>
                        <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
                      </tr>
                    </thead>
                    <tbody className="bg-white divide-y divide-gray-200">
                      {users.map((user) => (
                        <tr key={user.id} className="hover:bg-gray-50">
                          <td className="px-6 py-4 whitespace-nowrap">
                            <div className="flex items-center">
                              <div className="h-10 w-10 bg-purple-600 rounded-full flex items-center justify-center">
                                <span className="text-white text-sm font-medium">
                                  {user.name?.charAt(0)?.toUpperCase() || 'U'}
                                </span>
                              </div>
                              <div className="ml-4">
                                <div className="font-medium text-gray-900">{user.name}</div>
                                <div className="text-sm text-gray-500">ID: {user.id}</div>
                              </div>
                            </div>
                          </td>
                          <td className="px-6 py-4 whitespace-nowrap text-gray-500">
                            {user.email}
                          </td>
                          <td className="px-6 py-4 whitespace-nowrap text-gray-500">
                            <div>
                              <div>{new Date(user.createdAt).toLocaleDateString('ja-JP')}</div>
                              <div className="text-xs text-gray-400">
                                {new Date(user.createdAt).toLocaleTimeString('ja-JP')}
                              </div>
                            </div>
                          </td>
                          <td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
                            <button className="text-blue-600 hover:text-blue-900">詳細</button>
                            <button className="text-red-600 hover:text-red-900">削除</button>
                          </td>
                        </tr>
                      ))}
                      {users.length === 0 && !isLoading && (
                        <tr>
                          <td colSpan={4} className="px-6 py-8 text-center text-gray-500">
                            ユーザーが見つかりません
                          </td>
                        </tr>
                      )}
                    </tbody>
                  </table>
                </div>
              </div>
            )}
          </div>
        </div>
      </div>

      {/* 商品更新用Drawer */}
      <UpdateProductDrawer 
        isOpen={isOpen}
        onClose={handleClose}
        product={selectedProduct}
      />
    </div>
  );
} 

リストページ

import { ProductListItem } from './ProductListItem';

interface Product {
  id: string;
  name: string;
  description?: string | null;
  price: number;
  stock: number;
  categoryId: string;
  category?: {
    id: string;
    name: string;
  };
}

interface ProductTableProps {
  products: Product[];
  onEdit: (product: Product) => void;
  isLoading: boolean;
}

export function ProductTable({ products, onEdit, isLoading }: ProductTableProps) {
  return (
    <div className="overflow-x-auto">
      <table className="min-w-full divide-y divide-gray-200">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              商品名
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              価格
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              在庫
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              カテゴリ
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
              操作
            </th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {products.map((product) => (
            <ProductListItem 
              key={product.id} 
              product={product} 
              onEdit={onEdit}
            />
          ))}
          {products.length === 0 && !isLoading && (
            <tr>
              <td colSpan={5} className="px-6 py-8 text-center text-gray-500">
                商品が見つかりません
              </td>
            </tr>
          )}
        </tbody>
      </table>
    </div>
  );
} 


リストアイテムページ


interface Product {
  id: string;
  name: string;
  description?: string | null;
  price: number;
  stock: number;
  categoryId: string;
  category?: {
    id: string;
    name: string;
  };
}

interface ProductListItemProps {
  product: Product;
  onEdit: (product: Product) => void;
}

export function ProductListItem({ product, onEdit }: ProductListItemProps) {
  return (
    <tr className="hover:bg-gray-50">
      <td className="px-6 py-4 whitespace-nowrap">
        <div className="flex items-center">
          <div className="h-10 w-10 flex-shrink-0">
            <div className="h-10 w-10 rounded-lg bg-gray-200 flex items-center justify-center">
              <svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
              </svg>
            </div>
          </div>
          <div className="ml-4">
            <div className="font-medium text-gray-900">{product.name}</div>
            <div className="text-sm text-gray-500 truncate max-w-xs">
              {product.description || '説明なし'}
            </div>
          </div>
        </div>
      </td>
      <td className="px-6 py-4 whitespace-nowrap text-gray-500">
        ¥{product.price.toLocaleString()}
      </td>
      <td className="px-6 py-4 whitespace-nowrap text-gray-500">
        <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
          product.stock > 10 
            ? 'bg-green-100 text-green-800' 
            : product.stock > 0 
            ? 'bg-yellow-100 text-yellow-800' 
            : 'bg-red-100 text-red-800'
        }`}>
          {product.stock}</span>
      </td>
      <td className="px-6 py-4 whitespace-nowrap text-gray-500">
        <span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
          {product.category?.name || 'カテゴリなし'}
        </span>
      </td>
      <td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
        <button 
          className="text-blue-600 hover:text-blue-900"
          onClick={() => onEdit(product)}
        >
          編集
        </button>
        <button className="text-red-600 hover:text-red-900">削除</button>
      </td>
    </tr>
  );
}


##エラーハンドリング

バックエンドで処理が失敗しても成功のToastが表示されてしまう問題ですね。承知いたしました。
これは非同期処理、特にGraphQLのmutation(データの新規登録や更新処理)を扱う際によくある課題です。原因と解決策を詳しく解説します。

問題の根本原因

ご指摘の通りtry...catchを使っていても、catchブロックでエラーを捕捉できていないのが原因です。

多くのGraphQLクライアントライブラリ(urqlApollo Clientなど)のmutationフックは、バックエンドでエラー(例:バリデーションエラー、DBの制約違反など)が発生しても、JavaScriptの例外(Exception)をthrowしません。

代わりに、mutationを実行した結果として、errorプロパティを含むオブジェクトを返します。

現在のコードの推測:
おそらく、以下のようなコードになっていると推測します。

// よくある間違いの例
const [updateResult, updateProduct] = useUpdateProductMutation();

const onSubmit = async (data) => {
  try {
    // この updateProduct() はエラーを throw しない
    await updateProduct({ id: '...', ...data });

    // そのため、バックエンドで失敗しても catch には飛ばず、ここが実行されてしまう
    toast.success('更新に成功しました!');

  } catch (error) {
    // 結果、このブロックは実行されない
    toast.error('更新に失敗しました。');
    console.error(error);
  }
};

このコードでは、await updateProduct(...)の処理自体は(ネットワークエラーなどが起きない限り)正常に完了するため、tryブロック内の処理が最後まで実行され、成功のToastが表示されてしまうのです。

解決策:mutationの実行結果を正しく判定する

解決策は、mutationを実行した後の戻り値オブジェクトをきちんと確認し、errorプロパティが存在するかどうかで成功・失敗を判定することです。

urqlを使用していると仮定して、具体的な改善コードを示します。

// 正しい判定方法の例
import { useUpdateProductMutation } from '@/graphql/generated.graphql';
import { toast } from 'react-hot-toast'; // or your preferred toast library

// ...コンポーネント内

const [updateResult, updateProduct] = useUpdateProductMutation();

const onSubmit = async (data) => {
  // ① mutationを実行し、結果を受け取る
  const result = await updateProduct({ id: '...', ...data });

  // ② 結果オブジェクトの error プロパティを確認
  if (result.error) {
    // ③ エラーがあれば、失敗のToastを表示
    // result.error.message にはGraphQLサーバーからのエラーメッセージが含まれる
    toast.error(`更新に失敗しました: ${result.error.message}`);
    console.error('GraphQL Mutation Error:', result.error);
  } else {
    // ④ errorがなければ、成功のToastを表示
    toast.success('更新に成功しました!');
    // onClose(); // モーダルを閉じるなどの成功時の処理
  }
};

ポイント

  1. await updateProduct(...)でmutationを実行し、その戻り値をresult変数に格納します。
  2. if (result.error)という条件分岐で、GraphQLサーバーからエラーが返されなかったかを確認します。
  3. result.errorが存在する場合、その中のmessageプロパティを使って、ユーザーに具体的なエラー内容を提示できます。
  4. errorが存在しない(elseブロック)場合にのみ、成功とみなして成功のToastを表示します。

おすすめの実装:処理をカスタムフックにまとめる

この成功・失敗判定のロジックは、新規登録、更新、削除など、多くの場所で繰り返し使います。そこで、この処理を再利用可能なカスタムフックにまとめることを強くお勧めします。

以下に、汎用的なカスタムフックの例を示します。

1. カスタムフックを作成する (useSubmitWithToast.ts)

import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { OperationResult } from 'urql';

// フックのオプションの型定義
interface UseSubmitWithToastOptions<T> {
  mutation: (variables: T) => Promise<OperationResult<any>>;
  successMessage: string;
  onSuccess?: (result: OperationResult<any>) => void;
}

export function useSubmitWithToast<T>({ 
  mutation, 
  successMessage,
  onSuccess,
}: UseSubmitWithToastOptions<T>) {

  const [isSubmitting, setIsSubmitting] = useState(false);

  const submit = async (variables: T) => {
    if (isSubmitting) return;

    setIsSubmitting(true);
    const toastId = toast.loading('処理中...');

    try {
      const result = await mutation(variables);

      if (result.error) {
        toast.error(`エラー: ${result.error.message}`, { id: toastId });
        console.error('GraphQL Mutation Error:', result.error);
      } else {
        toast.success(successMessage, { id: toastId });
        if (onSuccess) {
          onSuccess(result);
        }
      }
    } catch (clientError) {
      // ネットワークエラーなど、クライアントサイドで発生した予期せぬエラー
      toast.error(`予期せぬエラーが発生しました: ${clientError.message}`, { id: toastId });
      console.error('Client-side Error:', clientError);
    } finally {
      setIsSubmitting(false);
    }
  };

  return { submit, isSubmitting };
}

2. フォームコンポーネントでカスタムフックを使用する

import { useUpdateProductMutation } from '@/graphql/generated.graphql';
import { useSubmitWithToast } from '@/hooks/useSubmitWithToast'; // 作成したフックをインポート

// ...コンポーネント内 (例: UpdateProductDrawer)

export function UpdateProductDrawer({ product, onClose }) {
  // urqlのmutationフック
  const [, updateProduct] = useUpdateProductMutation();

  // 作成したカスタムフックを使用
  const { submit, isSubmitting } = useSubmitWithToast({
    mutation: updateProduct,
    successMessage: '商品情報を更新しました!',
    onSuccess: () => {
      // 成功したらDrawerを閉じる
      onClose(); 
    },
  });

  // react-hook-formなどを使っている場合のハンドラ
  const onSubmit = async (data) => {
    // カスタムフックのsubmit関数を呼び出すだけ
    await submit({ id: product.id, ...data });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* ...フォームの入力フィールド... */}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '更新中...' : '更新する'}
      </button>
    </form>
  );
}

カスタムフック化するメリット

  • ロジックの再利用: 新規登録や削除など、他のフォームでも同じフックを使い回せます。
  • 関心の分離: フォームコンポーネントはUIの表示とデータ入力に集中でき、API通信やToast表示のロジックを分離できます。
  • UXの向上: isSubmittingのような状態を返すことで、「処理中...」のようにボタンを無効化したり、ローディング表示を簡単に追加できます。

この方法で、バックエンドのエラーを正しくハンドリングし、適切なToastメッセージを表示できるようになります。

ここでの解説:

  • useEffectによる初期値設定: 親から渡されるuserToEditオブジェクトが変更された(つまり、編集対象が選ばれた)ことを検知して、フォームのstateに初期値をセットしています。
  • 差分検出ロジック: handleSubmitの中で、for...inループを使ってformData(現在の入力値)とuserToEdit(元の値)をキーごとに比較し、値が異なるものだけをchangesオブジェクトに格納しています。
  • ペイロード作成: updateUserフックに渡すデータ(ペイロード)は、必須であるidと、変更点であるchangesオブジェクトを合体させて作ります。これにより、不要なデータをBFFに送ることを防ぎます。
  • ボタンの非活性化: 以前と同様に、リアルタイムの入力値に対してバリデーションを行い、その結果を使って更新ボタンの活性状態を制御しています。

これで、データを取得し、変更点だけを検出し、安全にサーバーに送信するという、実践的な更新フォームの全工程が完成しました。

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?