You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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 };

原因分析

  1. newCategory字段无默认值:表单初始化时,defaultValues未包含newCategory,导致该字段初始值为undefined。当选择“Create new”后,动态渲染的newCategory输入框挂载时,从React Hook Form拿到的field.value是undefined,此时Input组件处于非受控状态;用户开始输入后,值变为字符串,组件转为受控状态,触发React的“非受控转受控”警告。
  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 13:45:00