Skip to content

Preact Forms — Input Handling, Validation, and Submission

DodaTech Updated 2026-06-28 6 min read

Learn how to handle forms in Preact: controlled inputs, validation patterns, form submission, and building reusable form components in the 3kB framework.

In this lesson, you'll understand controlled components, form validation, custom hooks for form state, and submission to APIs.

What You'll Learn

How to build controlled form inputs, implement client-side validation, create reusable form field components, and handle form submission with async operations.

Why It Matters

Forms are how users interact with your application. Well-structured form handling prevents bugs, provides good user feedback, and ensures data integrity.

Real-World Use

DodaZIP's registration form uses controlled Preact inputs with real-time validation, async username availability checks, and debounced input for the email field.

flowchart LR
    A[User Input] --> B[Controlled Component]
    B --> C[State Update]
    C --> D[Validation]
    D --> E{Valid?}
    E -->|Yes| F[Submit Enabled]
    E -->|No| G[Error Display]
    F --> H[Async Submit]
    H --> I[Success / Error]
    style B fill:#673ab8,color:#fff
    style D fill:#4a148c,color:#fff

Controlled Inputs

React-style controlled components in Preact:

import { useState } from 'preact/hooks';

function LoginForm() {
  const [form, setForm] = useState({
    email: '',
    password: ''
  });
  const [errors, setErrors] = useState({});

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
    // Clear error when user starts typing
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Login:', form.email, form.password);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email:</label>
        <input type="email" name="email" value={form.email}
          onChange={handleChange} />
        {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      </div>
      <div>
        <label>Password:</label>
        <input type="password" name="password" value={form.password}
          onChange={handleChange} />
      </div>
      <button type="submit">Login</button>
    </form>
  );
}

Output: Typing in the fields updates the form state object. The inputs are controlled — their displayed value comes from state, not the DOM.

Form Validation

Implement real-time validation:

function validateForm(values) {
  const errors = {};

  if (!values.email) {
    errors.email = 'Email is required';
  } else if (!/\S+@\S+\.\S+/.test(values.email)) {
    errors.email = 'Invalid email format';
  }

  if (!values.password) {
    errors.password = 'Password is required';
  } else if (values.password.length < 8) {
    errors.password = 'Password must be at least 8 characters';
  }

  if (values.password !== values.confirmPassword) {
    errors.confirmPassword = 'Passwords do not match';
  }

  return errors;
}

function RegisterForm() {
  const [form, setForm] = useState({
    email: '', password: '', confirmPassword: ''
  });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const handleBlur = (e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    // Validate single field on blur
    const fieldErrors = validateForm(form);
    if (fieldErrors[name]) {
      setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
    }
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    const newForm = { ...form, [name]: value };
    setForm(newForm);

    // Re-validate touched fields
    if (touched[name]) {
      const newErrors = validateForm(newForm);
      setErrors(prev => ({
        ...prev,
        [name]: newErrors[name] || ''
      }));
    }
  };

  const isSubmittable = Object.keys(validateForm(form)).length === 0;

  return (
    <form>
      <input name="email" value={form.email}
        onChange={handleChange} onBlur={handleBlur} />
      {errors.email && <span class="error">{errors.email}</span>}
      {/* ... more fields ... */}
      <button type="submit" disabled={!isSubmittable}>Register</button>
    </form>
  );
}

Output: Validation runs on blur (when the user leaves a field) and on every change for already-touched fields. The submit button is disabled until all validations pass.

Custom useForm Hook

Extract form logic into a reusable hook:

import { useState, useCallback } from 'preact/hooks';

function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const newValue = type === 'checkbox' ? checked : value;
    setValues(prev => ({ ...prev, [name]: newValue }));

    if (touched[name]) {
      const newErrors = validate({ ...values, [name]: newValue });
      setErrors(prev => ({ ...prev, [name]: newErrors[name] || '' }));
    }
  }, [values, touched, validate]);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    const newErrors = validate(values);
    setErrors(prev => ({ ...prev, [name]: newErrors[name] || '' }));
  }, [values, validate]);

  const isValid = Object.keys(validate(values)).length === 0;

  return { values, errors, touched, isValid, handleChange, handleBlur, setValues };
}

function SignupForm() {
  const { values, errors, isValid, handleChange, handleBlur, setValues } =
    useForm({ email: '', password: '' }, validateForm);

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!isValid) return;
    await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={values.email}
        onChange={handleChange} onBlur={handleBlur} />
      {errors.email && <span class="error">{errors.email}</span>}
      <button type="submit" disabled={!isValid}>Sign Up</button>
    </form>
  );
}

Output: The useForm hook encapsulates all form state management. Any component can use it by providing initial values and a validation function.

Async Submission with Loading State

function AsyncForm() {
  const [form, setForm] = useState({ title: '', body: '' });
  const [submitting, setSubmitting] = useState(false);
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setSubmitting(true);
    setError(null);

    try {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form)
      });

      if (!res.ok) throw new Error('Submission failed');

      const data = await res.json();
      setResult(data);
      setForm({ title: '', body: '' }); // Reset on success
    } catch (err) {
      setError(err.message);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" value={form.title}
        onChange={e => setForm(p => ({ ...p, title: e.target.value }))}
        disabled={submitting} />
      <textarea name="body" value={form.body}
        onChange={e => setForm(p => ({ ...p, body: e.target.value }))}
        disabled={submitting} />
      <button type="submit" disabled={submitting}>
        {submitting ? 'Submitting...' : 'Submit'}
      </button>
      {error && <div class="error">{error}</div>}
      {result && <div class="success">Post created: {result.id}</div>}
    </form>
  );
}

Output: During submission, inputs are disabled and the button shows "Submitting...". On success, the form resets. On error, the error message displays.

Common Mistakes

  1. Not calling preventDefault() on form submit: Without it, the page reloads and all state is lost.
  2. Using onChange vs onInput confusion: Preact's onChange fires on blur for text inputs, while onInput fires on every keystroke. Use onInput for real-time updates.
  3. Forgetting to spread previous state: setForm({ ...form, [name]: value }) preserves other form fields. Omitting the spread overwrites the entire state.
  4. Not disabling submit during async operations: Users can click submit multiple times, causing duplicate requests. Disable the button while submitting.
  5. Validating only on submit: Real-time validation (on blur and on change for touched fields) provides better user experience than showing all errors at once on submit.

Practice Questions

  1. What is a controlled component in Preact? Answer: A form element whose value is controlled by Preact state. The value prop comes from state, and onChange updates that state.

  2. Why do you need preventDefault() in form handlers? Answer: To prevent the browser's default form submission behavior, which reloads the page and loses application state.

  3. What is the difference between onChange and onInput in Preact? Answer: onChange fires when the input loses focus (blur). onInput fires on every keystroke. Preact doesn't simulate React's onChange behavior.

  4. How do you prevent double form submission? Answer: Track a submitting state and disable the submit button while submitting is true.

Challenge

Build a multi-step form wizard with validation per step. Each step has its own validation rules, and the user can navigate forward and backward. Data is collected from all steps and submitted at the end.

Mini Project

Create a product order form with fields for shipping address, payment details, and order items. Include field-level validation, a loading state on submit, and success/error feedback.

FAQ

Does Preact support form validation libraries?

: Yes. Use react-hook-form or formik through preact/compat, or build custom validation with hooks.

How do I handle file uploads in Preact?

: Use <input type="file"> with a controlled component. Read the file with FileReader API and set state with the result.

Can I use uncontrolled inputs in Preact?

: Yes. Use ref to access the input's value directly. Uncontrolled inputs are useful for simple forms or integrating with non-Preact code.

Does Preact support form dirty tracking?

: Not built-in. Track touched state manually or use a form library like react-hook-form through compat.

What's Next

Learn about Preact Debugging and DevTools to troubleshoot and profile Preact applications effectively.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro