Implement a useInputControl hook that manages a controlled input value and tracks additional form input states like:
| Property | Tracks | When it becomes true | When it becomes false |
|---|---|---|---|
| Touched | Whether the input has been focused and then blurred | When the user blurs the input (focus -> blur) | Never resets automatically |
| Dirty | Whether the value has changed at least once | When the user types something | Never resets automatically |
| Different | Whether the value is different from the original | When the value is different from the initial value | When the value is the same as the initial value |
The handleX functions returned by the hook are meant to be passed to the relevant <input> event handlers for the hook to work as intended.
export default function Component() {const nameInput = useInputControl('Oliver');return (<form><div><label htmlFor="name">Name</label><inputid="name"value={nameInput.value}onChange={nameInput.handleChange}onBlur={nameInput.handleBlur}/></div><p>Touched: {nameInput.touched.toString()}</p><p>Dirty: {nameInput.dirty.toString()}</p><p>Different: {nameInput.different.toString()}</p><button type="submit" disabled={!nameInput.different}>Submit</button><button type="button" onClick={nameInput.reset}>Reset</button></form>);}
initialValue: string: The initial value of the inputThe hook returns an object with the following properties:
value: string: The current value of the inputdirty: boolean: Whether the input value has been modified at least oncetouched: boolean: Whether the input was focused and blurreddifferent: boolean: Whether the value is different from the initial valuehandleChange: (event: React.ChangeEvent<HTMLInputElement>) => void: A function that updates the value of the inputhandleBlur: () => void: A function that should be called when the input is blurredreset: () => void: A function that resets the input value and all flags back to their initial statedifferent against the initial value from the first render, not against later initialValue arguments.dirty stays true once the user has changed the value, even if the value later matches the initial value again.touched becomes true after blur and does not reset unless reset() is called.reset() should restore the first-render value and clear both dirty and touched.useInputControl has two pieces:
The important detail is that not every returned field should be stored in state. value, dirty, and touched represent history or user interaction, so they need state. different is only a comparison between the current value and the original value, so it can be derived during render.
That gives the hook a stable public API: handlers mutate the state layout, reset() restores the first value, and different is recomputed from the current render instead of being manually synchronized.
const initialValueRef = useRef(initialValue);const [value, setValue] = useState(initialValue);const [dirty, setDirty] = useState(false);const [touched, setTouched] = useState(false);const different = initialValueRef.current !== value;
initialValueRef is the baseline for the lifetime of this input control. This is different from reading initialValue directly on every render. If the parent later rerenders with a different initialValue prop, this existing control should still compare against the value it was mounted with.
The event handlers each update one part of the state picture. handleChange() reads event.currentTarget.value, writes it to value, and marks the input as dirty. handleBlur() marks it as touched. The hook trusts callers to connect those handlers to the matching input events.
const handleChange = useCallback((event) => {setValue(event.currentTarget.value);setDirty(true);}, []);const handleBlur = useCallback(() => {setTouched(true);}, []);
reset() restores the frozen baseline and clears the interaction flags. After a reset, different naturally becomes false because the current value once again matches initialValueRef.current.
const reset = useCallback(() => {setValue(initialValueRef.current);setDirty(false);setTouched(false);}, []);
The main distinction is between "has changed before" and "is different now". If the user changes the value and then types the original value again, dirty remains true, but different becomes false.
This is the main interview tradeoff: a ref is the right tool for the baseline because it persists without rerendering or tracking later prop changes, while state is reserved for values that should affect what the component renders.
For an initial value of "A", that distinction looks like this:
| Event | Value | dirty | touched | different |
|---|---|---|---|---|
| Initial render | "A" | false | false | false |
Change to "B" | "B" | true | false | true |
| Blur | "B" | true | true | true |
Change back to "A" | "A" | true | true | false |
| Reset | "A" | false | false | false |
import { useCallback, useRef, useState } from 'react';const defaultDirty = false;const defaultTouched = false;/*** @param {string} initialValue* @returns {{* value: string,* dirty: boolean,* touched: boolean,* different: boolean,* handleChange: (event: import('react').ChangeEvent<HTMLInputElement>) => void,* handleBlur: () => void,* reset: () => void,* }}*/export default function useInputControl(initialValue) {// Keep the first value stable so reset() and different compare against the original input.const initialValueRef = useRef(initialValue);const [value, setValue] = useState(initialValue);const [dirty, setDirty] = useState(defaultDirty);const [touched, setTouched] = useState(defaultTouched);const handleChange = useCallback((event) => {setValue(event.currentTarget.value);setDirty(true);}, []);const handleBlur = useCallback(() => {setTouched(true);}, []);const reset = useCallback(() => {setValue(initialValueRef.current);setDirty(defaultDirty);setTouched(defaultTouched);}, []);// different is derived from the frozen initial value rather than the latest prop.const different = initialValueRef.current !== value;return {value,dirty,touched,different,handleChange,handleBlur,reset,};}
Comparing against the latest argument: It is tempting to compute different as initialValue !== value, but that makes the baseline move whenever the parent rerenders with a new argument. The hook should compare against the value from the first render instead.
Storing different: different does not need its own state. Storing it would make the hook update two pieces of state whenever the value changes, and those pieces can drift if one update is missed. Computing it from initialValueRef.current and value keeps it consistent.
Treating dirty and different as the same flag: Returning to the initial value clears different, but it does not clear dirty. dirty tracks whether a change ever happened, not whether there is currently an unsaved difference.
reset() returns the current value to the frozen initial value and clears both dirty and touched. It does not need to set different directly because different is derived from value.
handleChange() reads event.currentTarget.value, which is the input element the handler was attached to. That keeps the hook aligned with React's typed change event.
This hook only manages a string input value and three form-state flags. It does not perform validation, track focus separately from blur, or synchronize an already-mounted control to a new initialValue.
console.log() statements will appear here.