← Back to Blog
Building Enterprise Forms: Complex Validation and UX

Enterprise forms are deceptively complex. A simple project creation form in Catalyst PSA grew to 47 fields across 4 steps with 15 conditional validation rules. Building forms that handle this complexity while remaining user-friendly taught me that great form UX is an art.

Here's everything I've learned about building enterprise forms over 27 years.

The Stack: React Hook Form + Zod

After trying Formik, Redux Form, and custom solutions, React Hook Form + Zod became our standard:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
 
const projectSchema = z.object({
  name: z.string().min(1, 'Project name is required').max(255),
  clientId: z.string().uuid('Invalid client'),
  budget: z.number().min(0).max(10000000).optional(),
  startDate: z.date(),
  endDate: z.date().optional(),
  description: z.string().max(5000).optional(),
}).refine((data) => {
  // Custom validation: endDate must be after startDate
  if (data.endDate && data.endDate < data.startDate) {
    return false;
  }
  return true;
}, {
  message: 'End date must be after start date',
  path: ['endDate'],
});
 
type ProjectFormData = z.infer<typeof projectSchema>;
 
export const ProjectForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ProjectFormData>({
    resolver: zodResolver(projectSchema),
  });
 
  const onSubmit = async (data: ProjectFormData) => {
    await api.createProject(data);
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Project'}
      </button>
    </form>
  );
};

Multi-Step Forms with Wizards

Complex forms should be broken into steps:

const projectWizardSchema = z.object({
  // Step 1: Basic Info
  name: z.string().min(1).max(255),
  clientId: z.string().uuid(),
  description: z.string().max(5000).optional(),
 
  // Step 2: Budget & Timeline
  budget: z.number().min(0).optional(),
  startDate: z.date(),
  endDate: z.date().optional(),
 
  // Step 3: Team
  managerId: z.string().uuid(),
  teamMembers: z.array(z.string().uuid()).min(1),
 
  // Step 4: Billing
  billingType: z.enum(['fixed-price', 'time-materials']),
  hourlyRate: z.number().optional(),
});
 
export const ProjectWizard = () => {
  const [step, setStep] = useState(1);
  const { register, handleSubmit, watch, formState: { errors } } = useForm({
    resolver: zodResolver(projectWizardSchema),
    mode: 'onChange', // Validate as user types
  });
 
  const billingType = watch('billingType');
 
  const onSubmit = async (data: ProjectFormData) => {
    if (step < 4) {
      // Move to next step
      setStep(step + 1);
    } else {
      // Final step, submit
      await api.createProject(data);
    }
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <ProgressIndicator current={step} total={4} />
 
      {step === 1 && (
        <div>
          <h2>Basic Information</h2>
          <Input label="Project Name" {...register('name')} error={errors.name} />
          <Select label="Client" {...register('clientId')} error={errors.clientId} />
        </div>
      )}
 
      {step === 2 && (
        <div>
          <h2>Budget & Timeline</h2>
          <Input type="number" label="Budget" {...register('budget')} />
          <DatePicker label="Start Date" {...register('startDate')} />
        </div>
      )}
 
      {step === 3 && (
        <div>
          <h2>Team</h2>
          <Select label="Project Manager" {...register('managerId')} />
          <MultiSelect label="Team Members" {...register('teamMembers')} />
        </div>
      )}
 
      {step === 4 && (
        <div>
          <h2>Billing</h2>
          <Select label="Billing Type" {...register('billingType')} />
 
          {/* Conditional field */}
          {billingType === 'time-materials' && (
            <Input
              type="number"
              label="Hourly Rate"
              {...register('hourlyRate')}
              required
            />
          )}
        </div>
      )}
 
      <div className="flex justify-between mt-6">
        {step > 1 && (
          <Button type="button" onClick={() => setStep(step - 1)}>
            Back
          </Button>
        )}
 
        <Button type="submit">
          {step === 4 ? 'Create Project' : 'Next'}
        </Button>
      </div>
    </form>
  );
};

Persist Form State Between Steps

Don't lose data when users navigate back:

import { useLocalStorage } from '@/hooks/useLocalStorage';
 
export const ProjectWizard = () => {
  const [step, setStep] = useState(1);
 
  const { register, handleSubmit, watch } = useForm({
    defaultValues: useLocalStorage('projectWizard', {}),
  });
 
  // Save to localStorage on every change
  const formData = watch();
 
  useEffect(() => {
    localStorage.setItem('projectWizard', JSON.stringify(formData));
  }, [formData]);
 
  const onSubmit = async (data: ProjectFormData) => {
    await api.createProject(data);
 
    // Clear saved data after successful submit
    localStorage.removeItem('projectWizard');
  };
};

Conditional Validation

Validation rules change based on other fields:

const projectSchema = z.discriminatedUnion('billingType', [
  z.object({
    billingType: z.literal('fixed-price'),
    fixedPrice: z.number().min(1, 'Fixed price required'),
    // hourlyRate not needed
  }),
  z.object({
    billingType: z.literal('time-materials'),
    hourlyRate: z.number().min(1, 'Hourly rate required'),
    // fixedPrice not needed
  }),
]);
 
// Or with custom validation
const projectSchema = z.object({
  billingType: z.enum(['fixed-price', 'time-materials']),
  fixedPrice: z.number().optional(),
  hourlyRate: z.number().optional(),
}).superRefine((data, ctx) => {
  if (data.billingType === 'fixed-price' && !data.fixedPrice) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Fixed price is required',
      path: ['fixedPrice'],
    });
  }
 
  if (data.billingType === 'time-materials' && !data.hourlyRate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Hourly rate is required',
      path: ['hourlyRate'],
    });
  }
});

Autosave for Long Forms

Save progress automatically to prevent data loss:

export const ProjectForm = () => {
  const { register, watch } = useForm();
  const formData = watch();
 
  // Debounced autosave
  useEffect(() => {
    const timeoutId = setTimeout(() => {
      api.saveProjectDraft(formData);
    }, 2000); // Save 2 seconds after user stops typing
 
    return () => clearTimeout(timeoutId);
  }, [formData]);
 
  return (
    <form>
      <AutosaveIndicator />
      {/* form fields */}
    </form>
  );
};
 
const AutosaveIndicator = () => {
  const [saving, setSaving] = useState(false);
  const [lastSaved, setLastSaved] = useState<Date | null>(null);
 
  return (
    <div className="text-sm text-gray-500">
      {saving && 'Saving...'}
      {!saving && lastSaved && `Last saved ${formatDistanceToNow(lastSaved)} ago`}
    </div>
  );
};

Field-Level Validation

Show errors as users type (but only after they've touched the field):

export const ValidatedInput = ({ name, label, register, errors }) => {
  const [touched, setTouched] = useState(false);
 
  return (
    <div>
      <label>{label}</label>
      <input
        {...register(name)}
        onBlur={() => setTouched(true)}
        className={touched && errors[name] ? 'border-red-500' : ''}
      />
      {touched && errors[name] && (
        <span className="text-red-500 text-sm">{errors[name].message}</span>
      )}
    </div>
  );
};

Don't show validation errors before user has interacted with field.

Async Validation

Check uniqueness or external constraints:

const projectSchema = z.object({
  name: z.string().min(1),
  // ... other fields
});
 
export const ProjectForm = () => {
  const { register, setError, formState: { errors } } = useForm({
    resolver: zodResolver(projectSchema),
  });
 
  const validateProjectName = async (name: string) => {
    const exists = await api.checkProjectNameExists(name);
 
    if (exists) {
      setError('name', {
        type: 'manual',
        message: 'Project name already exists',
      });
      return false;
    }
 
    return true;
  };
 
  const handleSubmit = async (data: ProjectFormData) => {
    const isValid = await validateProjectName(data.name);
 
    if (!isValid) return;
 
    await api.createProject(data);
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <Input
        label="Project Name"
        {...register('name')}
        onBlur={async (e) => {
          await validateProjectName(e.target.value);
        }}
        error={errors.name}
      />
    </form>
  );
};

Dynamic Fields

Add/remove fields dynamically:

import { useFieldArray } from 'react-hook-form';
 
const projectSchema = z.object({
  tasks: z.array(z.object({
    name: z.string().min(1),
    assigneeId: z.string().uuid(),
    estimatedHours: z.number().min(0),
  })).min(1, 'At least one task required'),
});
 
export const ProjectForm = () => {
  const { register, control } = useForm({
    resolver: zodResolver(projectSchema),
    defaultValues: {
      tasks: [{ name: '', assigneeId: '', estimatedHours: 0 }],
    },
  });
 
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'tasks',
  });
 
  return (
    <form>
      <h3>Tasks</h3>
 
      {fields.map((field, index) => (
        <div key={field.id} className="flex gap-2">
          <Input {...register(`tasks.${index}.name`)} placeholder="Task name" />
          <Select {...register(`tasks.${index}.assigneeId`)} />
          <Input type="number" {...register(`tasks.${index}.estimatedHours`)} />
 
          <Button type="button" onClick={() => remove(index)}>
            Remove
          </Button>
        </div>
      ))}
 
      <Button
        type="button"
        onClick={() => append({ name: '', assigneeId: '', estimatedHours: 0 })}
      >
        Add Task
      </Button>
    </form>
  );
};

File Uploads

Handle files in forms:

export const ProjectForm = () => {
  const [files, setFiles] = useState<File[]>([]);
  const [uploading, setUploading] = useState(false);
 
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    }
  };
 
  const uploadFiles = async () => {
    setUploading(true);
 
    const formData = new FormData();
    files.forEach(file => formData.append('files', file));
 
    const response = await api.uploadFiles(formData);
 
    setUploading(false);
 
    return response.urls;
  };
 
  const handleSubmit = async (data: ProjectFormData) => {
    // Upload files first
    const attachmentUrls = await uploadFiles();
 
    // Then create project with attachment URLs
    await api.createProject({
      ...data,
      attachments: attachmentUrls,
    });
  };
 
  return (
    <form>
      {/* other fields */}
 
      <div>
        <label>Attachments</label>
        <input
          type="file"
          multiple
          onChange={handleFileChange}
          accept=".pdf,.doc,.docx,.xls,.xlsx"
        />
 
        <ul>
          {files.map((file, index) => (
            <li key={index}>
              {file.name} ({(file.size / 1024).toFixed(2)} KB)
            </li>
          ))}
        </ul>
 
        {uploading && <ProgressBar />}
      </div>
    </form>
  );
};

Error Handling

Show field errors and form-level errors:

export const ProjectForm = () => {
  const [formError, setFormError] = useState<string | null>(null);
 
  const handleSubmit = async (data: ProjectFormData) => {
    try {
      setFormError(null);
      await api.createProject(data);
    } catch (error) {
      if (error.response?.status === 400) {
        // Validation errors
        setFormError(error.response.data.message);
      } else {
        // Server error
        setFormError('Something went wrong. Please try again.');
      }
    }
  };
 
  return (
    <form onSubmit={handleSubmit}>
      {formError && (
        <Alert type="error">
          {formError}
        </Alert>
      )}
 
      {/* fields */}
    </form>
  );
};

Accessibility

Forms must be accessible:

export const AccessibleInput = ({ label, name, error, ...props }) => {
  const inputId = `input-${name}`;
  const errorId = `error-${name}`;
 
  return (
    <div>
      <label htmlFor={inputId}>
        {label}
        {props.required && <span aria-label="required">*</span>}
      </label>
 
      <input
        id={inputId}
        name={name}
        aria-required={props.required}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
        {...props}
      />
 
      {error && (
        <span id={errorId} role="alert" className="text-red-500">
          {error.message}
        </span>
      )}
    </div>
  );
};

UX Best Practices

1. Inline Validation

Validate as user types (after first blur):

const { register, formState: { errors, touchedFields } } = useForm({
  mode: 'onTouched', // Validate after first blur, then on change
});

2. Clear Error Messages

❌ "Invalid input"
✅ "Project name must be between 1 and 255 characters"

❌ "Required"
✅ "Client is required"

❌ "Error"
✅ "End date must be after start date"

3. Prevent Duplicate Submissions

const [submitting, setSubmitting] = useState(false);
 
const handleSubmit = async (data: ProjectFormData) => {
  if (submitting) return;
 
  setSubmitting(true);
 
  try {
    await api.createProject(data);
  } finally {
    setSubmitting(false);
  }
};
 
return (
  <button type="submit" disabled={submitting}>
    {submitting ? 'Creating...' : 'Create Project'}
  </button>
);

4. Preserve Data on Error

Don't clear form when submission fails:

const handleSubmit = async (data: ProjectFormData) => {
  try {
    await api.createProject(data);
    reset(); // Clear form only on success
  } catch (error) {
    // Form data preserved, user can fix and retry
    setError('root', { message: error.message });
  }
};

5. Loading States

Show progress for long operations:

<Button type="submit" disabled={isSubmitting}>
  {isSubmitting ? (
    <>
      <Spinner />
      Creating project...
    </>
  ) : (
    'Create Project'
  )}
</Button>

Lessons Learned

  1. Break complex forms into steps: Multi-step wizards reduce cognitive load
  2. Autosave frequently: Prevent data loss on crashes or accidental navigation
  3. Validate early, not eagerly: After first blur, then on change
  4. Clear error messages: Help users fix problems
  5. Conditional validation: Rules change based on other fields
  6. Test accessibility: Screen readers, keyboard navigation
  7. Preserve state: Don't lose data on errors or navigation

Conclusion

Building great enterprise forms requires balancing complexity with usability. Multi-step wizards, conditional validation, autosave, and clear error messages make complex forms manageable.

After 27 years of building forms, I've learned that the best forms are invisible—users complete them without thinking about the form itself.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.