Working with Forms & User InputEntry 9 of 10
Reusable Inputs and a useInput Hook
Moving repeated input markup into an <Input> component and repeated state/validation logic into a custom hook.
Notes
Once a form has several validated fields, each one repeats the same label, input, error message, value state, didEdit state, and handlers. Two refactors remove that:
<Input>component: label + input + error, forwarding all other props ({...props}) to the<input>.useInput(defaultValue, validationFn)hook: ownsvalueanddidEdit, returns the value, change and blur handlers, andhasError.
Keep the validation rules as plain functions in a separate file so they can be reused and tested.
In React 19, ref is a normal prop, so <Input ref={…}> works without forwardRef.
What / Why / When
- What
Reusable form building blocks.
- Why
Less repetition, consistent error display, testable rules.
- When
Forms with three or more validated fields, or several forms in one app.
- Use case
The login form rewritten with
<Input>anduseInput.
Code compare
// util/validation.tsexport const isEmail = (value: string) => value.includes("@");export const isNotEmpty = (value: string) => value.trim() !== "";export const hasMinLength = (value: string, min: number) => value.length >= min;// hooks/useInput.tsimport { useState, type ChangeEvent } from "react";​export function useInput(defaultValue: string, validationFn: (value: string) => boolean) { const [value, setValue] = useState(defaultValue); const [didEdit, setDidEdit] = useState(false);​ const valueIsValid = validationFn(value);​ function handleInputChange(event: ChangeEvent<HTMLInputElement>) { setValue(event.target.value); setDidEdit(false); // hide the error while the user types again }​ function handleInputBlur() { setDidEdit(true); }​ return { value, handleInputChange, handleInputBlur, hasError: didEdit && !valueIsValid };}// components/Input.tsximport type { ComponentProps } from "react";​type InputProps = ComponentProps<"input"> & { label: string; id: string; error?: string | false };​export default function Input({ label, id, error, ...props }: InputProps) { return ( <div className="control"> <label htmlFor={id}>{label}</label> <input id={id} aria-invalid={!!error} aria-describedby={error ? `${id}-error` : undefined} {...props} /> {error && ( <p id={`${id}-error`} className="control-error"> {error} </p> )} </div> );}// Login.tsximport type { FormEvent } from "react";import Input from "./components/Input";import { useInput } from "./hooks/useInput";import { hasMinLength, isEmail, isNotEmpty } from "./util/validation";​export default function Login() { const email = useInput("", (value) => isEmail(value) && isNotEmpty(value)); const password = useInput("", (value) => hasMinLength(value, 6));​ function handleSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); if (email.hasError || password.hasError) return; console.log(email.value, password.value); }​ return ( <form onSubmit={handleSubmit} noValidate> <Input label="Email" id="email" type="email" name="email" value={email.value} onChange={email.handleInputChange} onBlur={email.handleInputBlur} error={email.hasError && "Please enter a valid email."} /> <Input label="Password" id="password" type="password" name="password" value={password.value} onChange={password.handleInputChange} onBlur={password.handleInputBlur} error={password.hasError && "Password must be at least 6 characters."} /> <button type="submit">Login</button> </form> );}Angular doesn't need a useInput equivalent: each FormControl already owns its value, touched state, and validators. A reusable input component takes the control as an input:
// form-field.component.tsimport { Component, input } from "@angular/core";import { FormControl, ReactiveFormsModule } from "@angular/forms";​@Component({ selector: "app-form-field", imports: [ReactiveFormsModule], template: ` <div class="control"> <label [for]="id()">{{ label() }}</label> <input [id]="id()" [type]="type()" [formControl]="control()" [attr.aria-invalid]="showError()" /> @if (showError()) { <p class="control-error">{{ error() }}</p> } </div> `,})export class FormFieldComponent { id = input.required<string>(); label = input.required<string>(); type = input("text"); error = input("Invalid value."); control = input.required<FormControl<string>>();​ showError() { return this.control().touched && this.control().invalid; }}<!-- login.component.html --><form [formGroup]="form" (ngSubmit)="onSubmit()"> <app-form-field id="email" label="Email" type="email" [control]="form.controls.email" error="Please enter a valid email." /> <app-form-field id="password" label="Password" type="password" [control]="form.controls.password" error="Password must be at least 6 characters." /> <button type="submit">Login</button></form>For a component that works with formControlName like a native input, implement ControlValueAccessor. That's more code and only worth it for shared UI libraries.
Differences and gotchas
- Your custom hook's state is per call:
emailandpasswordeach get their ownvalueanddidEdit(see Custom Hooks, Entry 4). - Spread
{...props}last if callers should be able to override defaults, or first if your component's values must win. - Angular's
showError()is a method called from the template; it re-runs on change detection, which is fine for a cheap check.
Quick reference
| React | Angular |
|---|---|
<Input {...props}> component | Component with [formControl] input |
useInput() hook | FormControl (built-in state + validators) |
util/validation.ts functions | ValidatorFn functions |
ref as a prop (React 19) | ControlValueAccessor for full integration |