React表单警告:非受控输入转为受控输入问题排查
React表单“非受控转受控”警告排查与解决
控制台警告信息
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components at input at _c (webpack-internal:///(app-pages-browser)/./components/ui/input.tsx:13:11) at eval (webpack-internal:///(app-pages-browser)/./node_modules/@radix-ui/react-slot/dist/index.mjs:46:23) at eval (webpack-internal:///(app-pages-browser)/./node_modules/@radix-ui/react-slot/dist/index.mjs:20:23) at eval (webpack-internal:///(app-pages-browser)/./components/ui/form.tsx:124:14) at div at eval (webpack-internal:///(app-pages-browser)/./components/ui/form.tsx:74:11) at Controller (webpack-internal:///(app-pages-browser)/./node_modules/react-hook-form/dist/index.esm.mjs:564:37) at FormField (webpack-internal:///(app-pages-browser)/./components/ui/form.tsx:29:14) at form at FormProvider (webpack-internal:///(app-pages-browser)/./node_modules/react-hook-form/dist/index.esm.mjs:182:13) at FormContent (webpack-internal:///(app-pages-browser)/./components/create-todo.tsx:52:88) at div at eval (webpack-internal:///(app-pages-browser)/./node_modules/@radix-ui/react-primitive/dist/index.mjs:44:26) at eval (webpack-internal:///(app-pages-browser)/./node_modules/@radix-ui/react-dismissable-layer/dist/index.mjs:44:42) at eval (webpack-internal:///(app-pages-browser)/./node_modules/@radix-ui/react-slot/dist/index.mjs:46:23) at eval (webpack-internal:///(app-pages-browser)/./node_modules/@radix-ui/react-slot/dist/index.mjs:20:23) at eval (webpack-internal:///(app-pages-browser)/./node_modules/@radix-ui/react-primitive/dist/index.mjs:44:26)
问题场景
基于Zod做表单验证的Todo创建表单,当选择“Create new”(创建新分类)时触发上述警告,警告指向shadcn-ui引入的Input组件,但检查Input组件代码未发现异常。
触发问题的CreateTodoDialog组件代码
"use client"; import { toast } from "react-hot-toast"; import * as z from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { PencilLine, Plus } from "lucide-react"; import { Button } from "./ui/button"; import { SelectValue, SelectTrigger, SelectContent, SelectItem, Select, } from "@/components/ui/select"; import { useEffect, useRef, useState } from "react"; import { createTodo, getCategories } from "@/app/todos/actions"; import { Category } from "@/types/custom"; const formSchema = z .object({ title: z.string().min(3), date: z.string(), category: z.string(), newCategory: z.string().optional(), }) .refine( (data) => { if (data.newCategory?.toLowerCase().split(" ").join("") === "createnew") { return false; } else if (data.category === "Create new" && !data.newCategory) { return false; } return true; }, { message: "New category cannot be empty or named 'Create new'.", path: ["newCategory"], } ); function FormContent() { const [categories, setCategories] = useState<Category[] | null>(); useEffect(() => { const fetchCategories = async () => { const fetched_categories = await getCategories(); setCategories(fetched_categories); }; fetchCategories(); }, []); const formRef = useRef<HTMLFormElement>(null); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { title: "", date: "", }, }); const category = form.watch("category"); const handleSubmit = async (data: any) => { if (data.category === "Create new") { data.category = null; } else { data.newCategory = null; } await createTodo(data); formRef.current?.reset(); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} ref={formRef} className="w-full flex flex-col gap-4" > <FormField control={form.control} name="title" render={({ field }) => { return ( <FormItem> <FormLabel>Title</FormLabel> <FormControl> <Input placeholder="Title" type="text" {...field} /> </FormControl> <FormMessage /> </FormItem> ); }} /> <FormField control={form.control} name="date" render={({ field }) => { return ( <FormItem> <FormLabel>Date</FormLabel> <FormControl> <Input placeholder="Date" type="date" {...field} /> </FormControl> <FormMessage /> </FormItem> ); }} /> <FormField control={form.control} name="category" render={({ field }) => { return ( <FormItem> <FormLabel>Category</FormLabel> <Select onValueChange={(value) => { field.onChange(value); // Ensures value is passed to form control form.setValue("category", value); // Sets value directly to the form }} > <FormControl> <SelectTrigger> <SelectValue placeholder="Select a category" /> </SelectTrigger> </FormControl> <SelectContent> {categories && categories.map((category: any) => ( <SelectItem key={category.id} value={category.name}> {category.name} </SelectItem> ))} <SelectItem value="Create new">Create new</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> ); }} /> {category === "Create new" && ( <FormField control={form.control} name="newCategory" render={({ field }) => { return ( <FormItem> <FormLabel>New category</FormLabel> <FormControl> <Input placeholder="New category" type="text" {...field} /> </FormControl> <FormMessage /> </FormItem> ); }} /> )} <Button type="submit">Submit</Button> </form> </Form> ); } export default function CreateTodoDialog() { return ( <Dialog> <DialogTrigger className="flex gap-x-1 hover:cursor-pointer"> <Plus className="text-blue-500" /> Add a todo </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create a new todo</DialogTitle> </DialogHeader> <FormContent /> </DialogContent> </Dialog> ); }
Input组件代码
import * as React from "react"; import { cn } from "@/lib/utils"; export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} const Input = React.forwardRef<HTMLInputElement, InputProps>( ({ className, type, ...props }, ref) => { return ( <input type={type} className={cn( "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300", className )} ref={ref} {...props} /> ); } ); Input.displayName = "Input"; export { Input };
原因分析
newCategory字段无默认值:表单初始化时,defaultValues未包含newCategory,导致该字段初始值为undefined。当选择“Create new”后,动态渲染的newCategory输入框挂载时,从React Hook Form拿到的field.value是undefined,此时Input组件处于非受控状态;用户开始输入后,值变为字符串,组件转为受控状态,触发React的“非受控转受控”警告。category字段同样无默认值:虽然是Select组件,但初始化时field.value为undefined,也存在潜在的状态不一致问题,不过主要触发警告的是newCategory对应的Input。
解决方法
1. 补充所有表单字段的默认值
在useForm的defaultValues中添加category和newCategory的默认空字符串,确保所有字段初始化时都有确定值:
const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { title: "", date: "", category: "", // 给category设置默认空字符串 newCategory: "", // 给可选字段newCategory设置默认空字符串 }, });
2. 简化Select组件的事件处理
当前Select的onValueChange同时调用field.onChange和form.setValue,属于重复操作。field对象已经包含了表单控制的onChange方法,直接使用即可:
<Select onValueChange={field.onChange}> {/* 其他代码保持不变 */} </Select>
3. 优化表单重置逻辑
替换DOM ref的重置方式为React Hook Form自带的form.reset(),更符合库的使用规范:
const handleSubmit = async (data: any) => { if (data.category === "Create new") { data.category = null; } else { data.newCategory = null; } await createTodo(data); form.reset(); // 使用form.reset()替代ref重置 };
验证效果
修改后,newCategory输入框初始化时就有了空字符串的默认值,始终处于受控状态,不会再出现“非受控转受控”的警告;category字段的初始化状态也更稳定,避免了潜在的状态不一致问题。
内容的提问来源于stack exchange,提问作者RakibulB
相关产品推荐
相关产品推荐

