全栈开发

nextjs 表单提交防止重复hook实现

解决表单在提交过程中,后端接收响应结果跳转页面成功之前间隙,提交按钮再次可点击,用户此时点击了按钮造成重复提交的问题。

1、use-submit-lock.ts

import { useRef, useState } from "react";

export function useSubmitLock() {
    const lockedRef = useRef(false);
    const [locked, setLocked] = useState(false);

    const lock = () => {
        if (lockedRef.current) return false;
        lockedRef.current = true;
        setLocked(true);
        return true;
    };

    const unlock = () => {
        lockedRef.current = false;
        setLocked(false);
    };

    return { locked, lock, unlock };
}

2、登陆表单应用

export function LoginForm({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const nextUrl = searchParams.get("next");

  const form = useForm<LoginSchema>({
    defaultValues: loginDefaultValues,
    resolver: zodResolver(loginSchema),
  });

  const loginMutation = useLogin();
  const { locked, lock, unlock } = useSubmitLock();

  const onSubmit: SubmitHandler<LoginSchema> = (data) => {
    // 请求中 or 已锁定 → 直接拦截
    if (loginMutation.isPending || locked) return;

    loginMutation.mutate(data, {
      onSuccess: (res) => {
        // 登录失败:解锁,允许再次提交
        if (!res.success) {
          unlock();
          return;
        }

        // 登录成功:锁死直到页面卸载
        lock();

        const redirectUrl =
          nextUrl && nextUrl.startsWith("/")
            ? nextUrl
            : "/dashboard";

        router.push(redirectUrl);
      },
    });
  };

  const disabled = loginMutation.isPending || locked;

  return (
    <div className={cn("flex flex-col gap-6", className)} {...props}>
      <Card>
        <CardHeader>
          <CardTitle>登录平台</CardTitle>
          <CardDescription>
            请输入正确的邮箱和密码登录平台
          </CardDescription>
        </CardHeader>

        <CardContent>
          <FormProvider {...form}>
            <form onSubmit={form.handleSubmit(onSubmit)}>
              <FieldGroup>
                <ControlledInput
                  name="email"
                  label="邮箱:"
                  type="email"
                  placeholder="m@example.com"
                />

                <ControlledInput
                  name="password"
                  label="密码:"
                  type="password"
                  placeholder="••••••••"
                />

                <Field>
                  <Button type="submit" disabled={disabled}>
                    {disabled ? "登录中..." : "登录"}
                  </Button>
                </Field>
              </FieldGroup>
            </form>
          </FormProvider>
        </CardContent>
      </Card>
    </div>
  );
}

3、用户信息更新表单应用

interface UserFormSheetProps {
    currentUser: {
        id: string;
        fullName: string;
        email: string;
    };
}

export function UserFormSheet({ currentUser }: UserFormSheetProps) {
    const [open, setOpen] = useState(false);
    const { locked, lock, unlock } = useSubmitLock();

    const form = useForm<UpdateUserSchema>({
        defaultValues: updateUserDefaultValues,
        resolver: zodResolver(updateUserSchema),
    });

    useEffect(() => {
        if (open) {
            form.reset({
                fullName: currentUser.fullName,
                email: currentUser.email,
                id: currentUser.id
            });
            unlock();
        }
    }, [open, currentUser, form]);

    const updateCurrentUserMutation = useUpdateCurrentUser();

    const onSubmit: SubmitHandler<UpdateUserSchema> = (data) => {
        if (updateCurrentUserMutation.isPending || locked) {
            return;
        }
        lock();
        updateCurrentUserMutation.mutate(data, {
            onSuccess: () => {
                setOpen(false)
            },
            onError: () => {
                unlock();
            }
        });
    };
    // React 组件每次渲染都会重新执行UserFormSheet函数体
    // 每当 状态(state)或 props 变化 导致组件重新渲染时,disabled 就会重新计算。
    // 所以它的值是动态的,会随着 updateCurrentUserMutation.isPending 或 locked 的变化而变化。
    // 当 mutation 状态或 locked 变化时,React 会触发重新渲染 → disabled 重新计算 → 按钮禁用状态更新
    const disabled = updateCurrentUserMutation.isPending || locked;

    return (
        <Sheet open={open} onOpenChange={setOpen}>
            <SheetTrigger asChild>
                <Button>编辑信息</Button>
            </SheetTrigger>

            <SheetContent>
                <SheetHeader>
                    <SheetTitle>编辑用户信息</SheetTitle>
                    <SheetDescription>
                        修改你的姓名和邮箱信息点击保存以更新
                    </SheetDescription>
                </SheetHeader>

                <FormProvider {...form}>
                    <form
                        id="user-form-sheet"
                        onSubmit={form.handleSubmit(onSubmit)}
                        className="grid flex-1 auto-rows-min gap-6 px-4"
                    >
                        <div className="space-y-4">
                            <ControlledInput name="fullName" label="姓名" placeholder="请输入姓名" />
                            <ControlledInput name="email" label="邮箱" placeholder="请输入邮箱" type="email" />
                        </div>
                    </form>
                </FormProvider>
                <SheetFooter>
                    <Button
                        form="user-form-sheet"
                        type="submit"
                        disabled={disabled}
                    >
                        {disabled ? "保存中..." : "保存"}
                    </Button>
                    <SheetClose asChild>
                        <Button variant="outline">
                            取消
                        </Button>
                    </SheetClose>
                </SheetFooter>
            </SheetContent>
        </Sheet>
    );
}

文字版的流程图,直观展示 useSubmitLock + mutation 状态 → 按钮 disabled/文本变化 的过程。

用户点击提交按钮
        
        
 ┌──────────────────────────┐
  检查是否锁定或请求中     
  locked || isPending ?    
 └─────────┬────────────────┘
            
           
     阻止重复提交
           
           └──> 按钮保持 disabled
           
            
           
 ┌──────────────────────────┐
  调用 lock() 上锁         
  mutation.mutate() 提交   
 └─────────┬────────────────┘
           
           
 ┌───────────────┐
  mutation 状态 
 └───────────────┘
           
  ┌────────┴───────────┐
                      
成功                  失败
                      
                      
按钮保持锁定           调用 unlock() 解锁
disabled = true        disabled = false
按钮文本: "提交中..."   按钮文本: "提交"

🔹 流程说明

  1. 提交前检查锁
    • 如果已经 locked 或 mutation 正在进行 (isPending) → 拦截,按钮保持禁用
  2. 提交时上锁
    • 调用 lock()locked = true
    • mutation 发起请求 → isPending = true
  3. 请求成功或失败
    • 成功:保持锁定(可选择让表单关闭或跳转)
    • 失败:调用 unlock()locked = false,按钮可再次点击
  4. 按钮状态绑定
    • disabled = locked || isPending
    • 文本根据 disabled 改变:disabled ? "提交中..." : "提交"

React 组件状态变化时间线图,展示从点击按钮到请求成功/失败的全过程,直观理解 useSubmitLock 的工作原理。

时间线 ──────────────────────────────►

用户点击提交按钮

 组件渲染:
 locked = false
 isPending = false
 disabled = false
 按钮显示 "提交"


提交逻辑触发:
if (locked || isPending) return;
lock() → locked = true
mutation.mutate() → isPending = true
组件重新渲染:
disabled = locked || isPending  true
按钮显示 "提交中..."


mutation 请求进行中:
 locked = true
 isPending = true
 disabled = true
 用户无法再次点击


请求完成:
├─ 成功:
   locked = true (保持锁定)
   isPending = false
   disabled = true
   按钮显示 "提交中..."(或根据业务显示“成功”)
   表单可能关闭或跳转

└─ 失败:
    unlock() → locked = false
    isPending = false
    disabled = false
    按钮显示 "提交"
    用户可重新提交

🔹 流程总结

  1. 锁定逻辑locked 防止重复点击
  2. 请求状态isPending 表示 mutation 还在进行
  3. 按钮状态绑定
const disabled = locked || mutation.isPending;
  • 自动同步 UI
  • 文本可以直接绑定:
<Button disabled={disabled}>
  {disabled ? "提交中..." : "提交"}
</Button>

失败解锁:保证用户在请求失败后可以再次提交

图解版 Hook 防重复提交的组件交互示意图,用箭头和框展示 lockedisPending、按钮状态变化的逻辑关系。

┌──────────────┐
 用户点击提交 
└───────┬──────┘
        
        
┌───────────────────────┐
 检查是否 locked      
 mutation.isPending    
 如果是  阻止提交      
└─────────┬─────────────┘
           
          
┌───────────────────────┐
 调用 lock() 上锁       
 locked = true          
 mutation.mutate() 发起 
 请求  isPending = true
└─────────┬─────────────┘
          
          
┌───────────────────────────┐
 组件重新渲染:             
 disabled = locked || isPending 
 按钮显示 "提交中..."        
└─────────┬─────────────────┘
          
          
┌───────────────┐           ┌───────────────┐
 mutation 成功              mutation 失败  
└───────┬───────┘           └───────┬───────┘
                                   
                                   
┌───────────────┐           ┌───────────────┐
 锁保持 locked              调用 unlock() 
 isPending = false          locked = false 
 disabled = true             isPending = false 
 按钮显示 "提交中..."         disabled = false 
 表单关闭或跳转              按钮显示 "提交" 
└───────────────┘           └───────────────┘

🔹 图解说明

  1. 点击提交 → 首先检查 locked || isPending,防止重复提交
  2. 提交开始 → 调用 lock(),mutation 发起请求,按钮变为禁用
  3. 请求状态isPending + locked 控制按钮禁用和文本
  4. 请求结束
    • 成功 → 锁保持直到页面跳转/表单关闭
    • 失败 → 调用 unlock() 解锁,允许用户再次提交