实现FluentValidation错误字段映射展示及React Query去useEffect优化
解决方案:FluentValidation错误映射至Fluent UI表单+React Query优化
问题背景
- API验证失败时返回400错误,后端通过ModelState返回FluentValidation错误,但错误键为Validator类名(如
NotEmptyValidator),无法直接对应到表单输入字段 - 前端使用React Query+Axios,但依赖
useEffect处理请求成功/错误逻辑,未充分利用React Query原生能力
步骤1:后端调整 - 让错误键对应表单字段名
当前后端用error.Code(Validator类名)作为ModelState的键,需要改为表单字段名。可通过正则从错误描述中提取字段名,确保前端能直接映射到对应输入框。
修改后端端点代码
using Ardalis.ApiEndpoints; using MediatR; using Microsoft.AspNetCore.Mvc; using QueryStore.Application.Queries; using System.Text.RegularExpressions; namespace QueryStore.API.Endpoints.Queries; public sealed class CreateQueryEndpoint : EndpointBaseAsync .WithRequest<CreateQueryCommand> .WithActionResult<int> { private readonly IMediator _mediator; public CreateQueryEndpoint(IMediator mediator) { _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); } [HttpPost("/queries")] public override async Task<ActionResult<int>> HandleAsync([FromBody] CreateQueryCommand request, CancellationToken cancellationToken = default) { var result = await _mediator.Send(request, cancellationToken); if (!result.IsSuccess) { foreach (var error in result.Errors) { // 从错误描述中提取字段名,例如"'Name' must not be empty." -> "Name" var fieldNameMatch = Regex.Match(error.Description, @"^'([^']+)'"); var fieldName = fieldNameMatch.Success ? fieldNameMatch.Groups[1].Value : error.Code; ModelState.AddModelError(fieldName, error.Description); } return BadRequest(ModelState); } var getByIdUrl = Url.RouteUrl(GetQueryByIdEndpoint.RouteName) ?? string.Empty; return Created(getByIdUrl, result); } }
步骤2:前端优化 - 移除useEffect,用React Query原生回调+错误映射至Fluent UI表单
核心优化点
- 用React Query
useMutation的onSuccess/onError/onMutate替代useEffect处理状态 - 将后端返回的错误直接映射到对应表单字段,通过Fluent UI的
Field组件展示字段级错误 - 优化用户交互:输入时清除字段错误、请求中禁用按钮
修改后前端组件代码
import { makeStyles, shorthands, Field, Input, Button, tokens, useId, useToastController, Toast, ToastTitle, Toaster, } from "@fluentui/react-components"; import { Add16Filled } from "@fluentui/react-icons"; import axios from "axios"; import { useState } from "react"; import { useMutation } from "react-query"; interface CreateQuery { name: string; description: string; content: string; tags: string[]; } interface CreateQueryProps { setComponent: (component: string) => void; } const useStyles = makeStyles({ root: { display: "flex", fontWeight: tokens.fontWeightBold, flexDirection: "column", ...shorthands.gap("20px"), maxWidth: "600px", marginLeft: "20px", marginTop: "20px", }, primaryButton: { marginRight: "8px", }, }); export default function CreateQuery({ setComponent }: CreateQueryProps) { const classes = useStyles(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [content, setContent] = useState(""); const [tags, setTags] = useState<string[]>([]); const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); const handleTags = (tags: string) => { setTags(tags.split(",").map(tag => tag.trim())); }; const createQuery = async ({ name, description, content, tags }: CreateQuery) => { try { const response = await axios.post("https://localhost:8443/queries", { name, description, content, tags, }); return response.data; } catch (err: any) { if (err.response?.status === 400) { throw new Error(JSON.stringify(err.response.data)); } throw err; } }; const errorToasterId = useId("error"); const { dispatchToast: dispatchError } = useToastController(errorToasterId); const mutation = useMutation(createQuery, { onSuccess: () => setComponent("home"), onError: (err: any) => { setFieldErrors({}); if (err.message.startsWith("{")) { const errorData = JSON.parse(err.message); const newFieldErrors: Record<string, string> = {}; Object.entries(errorData).forEach(([field, messages]) => { if (Array.isArray(messages) && messages.length > 0) { newFieldErrors[field.toLowerCase()] = messages[0]; dispatchError( <Toast> <ToastTitle>{messages[0]}</ToastTitle> </Toast>, { intent: "error" } ); } }); setFieldErrors(newFieldErrors); } else { dispatchError( <Toast> <ToastTitle>请求失败:{err.message}</ToastTitle> </Toast>, { intent: "error" } ); } }, onMutate: () => setFieldErrors({}) }); const submitData = () => { mutation.mutate({ name, description, content, tags }); }; return ( <div className={classes.root}> <Field size="medium" label="Name" required validationState={fieldErrors.name ? "error" : undefined} errorMessage={fieldErrors.name} > <Input appearance="outline" type="text" value={name} onChange={(e) => setName(e.target.value)} onFocus={() => setFieldErrors(prev => ({ ...prev, name: undefined }))} /> </Field> <Field label="Description" validationState={fieldErrors.description ? "error" : undefined} errorMessage={fieldErrors.description} > <Input appearance="outline" type="text" value={description} onChange={(e) => setDescription(e.target.value)} onFocus={() => setFieldErrors(prev => ({ ...prev, description: undefined }))} /> </Field> <Field label="Content" required validationState={fieldErrors.content ? "error" : undefined} errorMessage={fieldErrors.content} > <Input appearance="outline" type="text" value={content} onChange={(e) => setContent(e.target.value)} onFocus={() => setFieldErrors(prev => ({ ...prev, content: undefined }))} /> </Field> <Field label="Tags" validationState={fieldErrors.tags ? "error" : undefined} errorMessage={fieldErrors.tags} > <Input appearance="outline" type="text" value={tags.join(", ")} onChange={(e) => handleTags(e.target.value)} onFocus={() => setFieldErrors(prev => ({ ...prev, tags: undefined }))} /> </Field> <div> <Button className={classes.primaryButton} appearance="primary" icon={<Add16Filled />} onClick={submitData} disabled={mutation.isLoading} > {mutation.isLoading ? "Creating..." : "Create"} </Button> <Button onClick={() => setComponent("home")}>Cancel</Button> </div> <Toaster toasterId={errorToasterId} position="top-end" timeout={3000} /> </div> ); }
内容的提问来源于stack exchange,提问作者Hardik Dhuri
相关产品推荐
相关产品推荐

