# Submission Rule

**MUST** · **ID:** `forms-submission-rule` · **Category:** forms
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/forms-submission-rule

> MUST: Keep submit enabled until request starts; then disable, show spinner, use idempotency key

Keep submit button enabled until submission starts, then disable with loading state

Pre-disabling submit buttons prevents users from discovering validation errors. Let them try to submit, then show what needs fixing. Once submission starts, disable the button and show a loading indicator to prevent double submissions. Use idempotency keys on the backend to handle accidental duplicate requests.

## Bad — do not do this

`forms-submission-rule-bad`

```tsx
import { useState } from 'react';

export function SubmissionRuleBad() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const isFormValid = email.length > 0 && password.length >= 8;

  return (
    <div className="w-full max-w-sm">
      <form className="space-y-4">
        <div>
          <label htmlFor="bad-submit-email" className="block text-sm font-medium text-foreground mb-1">
            Email
          </label>
          <input
            id="bad-submit-email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
        </div>
        <div>
          <label htmlFor="bad-submit-password" className="block text-sm font-medium text-foreground mb-1">
            Password (min 8 characters)
          </label>
          <input
            id="bad-submit-password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
        </div>
        <button
          type="submit"
          disabled={!isFormValid}
          className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-primary transition-colors"
        >
          Sign Up
        </button>
      </form>
      <p className="text-xs text-muted-foreground mt-4">
        Button pre-disabled - can't discover validation issues
      </p>
    </div>
  );
}
```

## Good — do this

`forms-submission-rule-good`

```tsx
import { useState } from 'react';
import { Loader2 } from 'lucide-react';

export function SubmissionRuleGood() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const newErrors: { email?: string; password?: string } = {};
    if (!email) newErrors.email = 'Email is required';
    if (password.length < 8) newErrors.password = 'Password must be at least 8 characters';

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    setIsSubmitting(true);
    setTimeout(() => {
      setIsSubmitting(false);
      setErrors({});
    }, 2000);
  };

  return (
    <div className="w-full max-w-sm">
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label htmlFor="good-submit-email" className="block text-sm font-medium text-foreground mb-1">
            Email
          </label>
          <input
            id="good-submit-email"
            type="email"
            value={email}
            onChange={(e) => {
              setEmail(e.target.value);
              if (errors.email) setErrors({ ...errors, email: undefined });
            }}
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          {errors.email && (
            <p className="text-xs text-error mt-1">{errors.email}</p>
          )}
        </div>
        <div>
          <label htmlFor="good-submit-password" className="block text-sm font-medium text-foreground mb-1">
            Password (min 8 characters)
          </label>
          <input
            id="good-submit-password"
            type="password"
            value={password}
            onChange={(e) => {
              setPassword(e.target.value);
              if (errors.password) setErrors({ ...errors, password: undefined });
            }}
            className="w-full px-3 py-2 border border-border rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          {errors.password && (
            <p className="text-xs text-error mt-1">{errors.password}</p>
          )}
        </div>
        <button
          type="submit"
          disabled={isSubmitting}
          className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-70 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
        >
          {isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
          Sign Up
        </button>
      </form>
      <p className="text-xs text-success mt-4">
        Button enabled - shows validation on submit, loading state during submission
      </p>
    </div>
  );
}
```

## References

- [Idempotency](https://developer.mozilla.org/en-US/docs/Glossary/Idempotent)
