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:

  1. <Input> component: label + input + error, forwarding all other props ({...props}) to the <input>.
  2. useInput(defaultValue, validationFn) hook: owns value and didEdit, returns the value, change and blur handlers, and hasError.

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> and useInput.

Code compare

React
// 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;
React
// 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 };}
React
// 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>  );}
React
// 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:

Angular
// 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;  }}
Angular
<!-- 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: email and password each get their own value and didEdit (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

ReactAngular
<Input {...props}> componentComponent with [formControl] input
useInput() hookFormControl (built-in state + validators)
util/validation.ts functionsValidatorFn functions
ref as a prop (React 19)ControlValueAccessor for full integration