Build a custom React hook that owns a single controlled input and the two flags a form usually wants alongside it: dirty (has the value changed from where it started?) and touched (has the user left the field at least once?). Most forms re-implement this by hand for every field. useInputControl(initialValue) packages the value, its event handlers, and both flags into one object a component can wire to an <input> in a single line.
function useInputControl(initialValue?: string): {
value: string; // current input value
onChange: (event: { target: { value: string } }) => void; // set value from event
onBlur: () => void; // mark the field touched
dirty: boolean; // value !== ORIGINAL initialValue
touched: boolean; // field has been blurred once
reset: () => void; // restore value, clear flags
};
initialValue defaults to ''. dirty is measured against the value the hook was first created with, not against any later argument.
function EmailField() {
const email = useInputControl('');
return (
<label>
Email
<input value={email.value} onChange={email.onChange} onBlur={email.onBlur} />
{email.touched && email.dirty && <span>unsaved changes</span>}
<button onClick={email.reset}>Reset</button>
</label>
);
}
// typing flips dirty=true; leaving the field flips touched=true; Reset clears both.
// dirty tracks equality with the ORIGINAL initial value, both ways.
const f = useInputControl('alice');
// f.value === 'alice', f.dirty === false, f.touched === false
// onChange({ target: { value: 'bob' } }) → value 'bob', dirty true
// onChange({ target: { value: 'alice' } }) → value 'alice', dirty false again
// onBlur() → touched true
// reset() → value 'alice', dirty false, touched false
dirty is computed, not stored. It is the live comparison value !== originalInitial. Typing away from the start makes it true; typing back to the exact original makes it false again.dirty targets the FIRST initial value. If the component re-renders and calls the hook with a different initialValue argument, dirty must still compare against the value the hook was created with on its first render — snapshot it, do not read the live argument.touched is one-way until reset. It starts false, becomes true the first time onBlur runs, and stays true through later changes. Only reset() clears it.onChange reads event.target.value. It receives the DOM change event and pulls the new string from event.target.value, exactly like a native controlled input.reset restores everything. It returns value to the original initial value and sets both dirty and touched back to their starting state.onChange, onBlur, and reset should keep the same reference across re-renders so they are safe to pass to memoized children or list in effect dependencies.