Preact Forms — Input Handling, Validation, and Submission
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
- Not calling
preventDefault()on form submit: Without it, the page reloads and all state is lost. - Using
onChangevsonInputconfusion: Preact'sonChangefires on blur for text inputs, whileonInputfires on every keystroke. UseonInputfor real-time updates. - Forgetting to spread previous state:
setForm({ ...form, [name]: value })preserves other form fields. Omitting the spread overwrites the entire state. - Not disabling submit during async operations: Users can click submit multiple times, causing duplicate requests. Disable the button while submitting.
- 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
What is a controlled component in Preact? Answer: A form element whose value is controlled by Preact state. The
valueprop comes from state, andonChangeupdates that state.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.What is the difference between
onChangeandonInputin Preact? Answer:onChangefires when the input loses focus (blur).onInputfires on every keystroke. Preact doesn't simulate React's onChange behavior.How do you prevent double form submission? Answer: Track a
submittingstate and disable the submit button whilesubmittingis 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
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