Building Custom React Inputs
It starts with a design. It always starts with a design. "We need a tag selector," the designer says. "But not a boring select box. We want chips. We want them to animate. We want a little 'x' button on each one." So you build it. You create a div. You map over an array of strings. You add some onClick handlers. It looks beautiful. It's perfect. Then you try to use it in a form.
<form onSubmit={handleSubmit}>
<ChipsInput /> {/* The Zombie */}
<button>Submit</button>
</form>
- You hit enter. Nothing happens.
- You try to tab to it. You tab right past it.
- You try to read the
FormData. It's empty.
And suddenly, you realize you've built a zombie. It looks like an input, but it's dead inside. It has no soul. It has no value. It has no name.
Why are we still doing this? We do it because native inputs are notoriously hard to style. You can't put a chip inside a text input. You can't animate the caret. So we abandon the native element and rebuild the world from scratch with divs.
I look at the ecosystem. There are a thousand "React Select" libraries. They all have their own state management. They all have their own onChange signature that returns some custom object instead of an event. They all require you to manually wire them up to your form library.
I didn't want a "React Component." I wanted an input.
I wanted something that passes the Duck Test. If it walks like a duck, and quacks like a duck, it's a native input. Even if it's actually three raccoons in a trench coat.
The Epiphany: The Trojan Horse
I realized I was fighting the browser. I was trying to re-implement focus, blur, validation, and form submission in JavaScript. Why? The browser already does this. It's been doing it for 30 years. So I stopped fighting. I decided to cheat. I decided to build a Trojan Horse. On the outside, it's a beautiful, animated, interactive React component. But inside? Deep inside the DOM where the user can't see? It's just a boring, standard HTML string.
How it Works: The ChipsInput
Let's look at the ChipsInput. The goal is to let the user type multiple values, hit enter, and see them appear as "chips." But to the form, this should just be a comma-separated string. So, I hide the truth.
// 1. The Hidden Messenger
<input
aria-hidden
style={{
position: "absolute",
opacity: 0,
pointerEvents: "none",
width: 0,
height: 0,
}}
ref={hiddenInputRef}
name={name}
value={currentValues.join(",")} // "react,javascript,css"
onChange={validationHandlers.onChange}
onBlur={validationHandlers.onBlur}
required={required}
/>
This is the soul of the component. It's invisible. It takes up no space. But it holds the name. It holds the value. It holds the required attribute. When the form looks for data, it finds this input. When the browser checks for validity, it checks this input.
Syncing the State: Here Be Dragons
Now we just need to keep the visual state in sync with this hidden truth.
When you add a chip, I don't just update React state. I update the DOM. But you can't just set input.value = "foo". React tracks the input's value internally. If you modify the DOM directly, React's internal state gets out of sync with the DOM, and the next render might wipe out your changes.
We have to be sneaky. We have to bypass React's tracker by calling the native value setter from the prototype.
let setChange = (values: Array<string>) => {
// inputRef points to the hidden <input> element
if (inputRef.current) {
let value = values.join(separator);
// 2. The Manual Override
// We steal the setter from the HTMLInputElement prototype
// This allows us to set the value *without* React intercepting it immediately
let valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
// Call the native setter with our DOM node as 'this'
valueSetter?.call(inputRef.current, value);
// 3. The Fake Event
// Dispatch a change event so React (and the world) knows the value changed
inputRef.current.dispatchEvent(new Event("change", { bubbles: true }));
// Finally, update our local React state to match
setState(values);
}
};
I manually set the value on the DOM node using the prototype. This is crucial because it updates the value without React knowing immediately, but then the change event brings everyone back in sync. This tells the world (and any other listeners) that something happened. The form sees the update. The validation logic runs.
The Forgotten Lifecycle: Reset
There is one event that React developers almost always forget: reset. When you click <button type="reset">, the browser restores all inputs to their defaultValue. But since our "state" is in React useState, the browser's reset doesn't touch it. The hidden input reverts, but the chips stay on screen.
The fix? We listen to the form. But we have to wait.
useEffect(() => {
let form = wrapper.current.closest("form");
if (!form) return;
let handleReset = () => {
// Wait for the browser to finish its reset
requestAnimationFrame(() => {
setState(getValues(defaultValue));
});
};
form.addEventListener("reset", handleReset);
return () => form.removeEventListener("reset", handleReset);
}, [defaultValue]);
Why requestAnimationFrame? Because the reset event fires before the browser actually clears the inputs. If we read the value immediately, we'd get the old value. We have to let the browser finish its work, tick the frame, and then sync our React state to the new reality. (You could use setTimeout(..., 0) here too, but rAF is generally cleaner for visual updates).
The Focus Relay (formerly "Faking the Focus")
The hardest part isn't the data. It's the feel. A native input is a single focusable element. My component is a list of chips, a delete button for each chip, and a text input for adding new ones. If I tab from a chip to the delete button, have I "blurred" the field? Technically, yes. The focus left the container.
But to the user? No. They are still "in" the input. So we have to fake it. We have to simulate the focus cycle.
let handleInputBlur: FocusEventHandler<HTMLInputElement> = (e) => {
// ... logic to check if we moved to another element inside the same wrapper ...
setInputFocus(false);
simulateFocusCycle(inputRef.current);
};
We wait. We check document.activeElement. If the focus just moved to a neighbor, we do nothing. If it truly left the component, then we fire the onBlur event. But we don't just fire a React handler. We go deeper.
export function simulateFocusCycle(elm: ValidateElement | null) {
if (elm) {
elm.dispatchEvent(new Event("focusin", { bubbles: true }));
elm.dispatchEvent(new Event("focusout", { bubbles: true }));
}
}
We manually dispatch focusin and focusout events on the hidden input. Why? Because validation libraries (and our own code) often listen for these specific events to mark a field as "touched" or to trigger error messages. By manually firing them, we ensure that any external code watching our component sees a valid focus lifecycle, even though the user never actually touched that hidden input.
The Elephant in the Room: Accessibility
"But wait," I hear you say. "You have an invisible input with aria-hidden. If validation fails, the browser focuses that input. Won't the user be lost?"
Yes. That is the danger. If the browser focuses an opacity: 0 element, the user's focus ring disappears. They are tabbed into the void.
To fix this, we have to listen for the invalid event on the hidden input and manually shift focus back to our visible input.
hiddenInput.addEventListener('invalid', (e) => {
e.preventDefault(); // Stop the native browser bubble if it's confusing
visibleInputRef.current?.focus();
// Show our custom error UI
});
It's a trade-off. We get the native form behavior, but we have to manage the edge cases where the "native" behavior (focusing the invalid field) conflicts with our "custom" reality (the field is invisible).
I chose aria-hidden intentionally. The hidden input is for data, not for humans. The screen reader should interact with the list of chips and the text input, not the comma-separated string in the background. If we didn't hide it, the screen reader would announce the field twice: once for the custom UI, and once for the hidden value.
Advanced Moves: The DateInput
This pattern scales. Take a Date Input. Designers love splitting dates: Day, Month, Year. Browsers love ISO strings: YYYY-MM-DD. So we do the same trick, but with a coordinator. We render three visible inputs and one invisible master. But how do they talk to each other? We use React Context to create a mini-state machine.
- The Coordinator (
DateInput): This component holds the single source of truth—the ISO string. It parses this string intoday,month, andyearintegers and exposes them via a Context Provider. - The Players (
Day,Month,Year): These are dumb components. They don't know about the ISO string. They just consume the Context.<Day />readsctx.dayand callsctx.setDay(12).<MonthSelect />readsctx.monthand callsctx.setMonth(1).
- The Sync: When you change the Day, the
DateInputintercepts the update.- It takes the new Day.
- It combines it with the existing Month and Year.
- It constructs a new ISO string:
2024-02-12. - It updates the hidden
<input type="date" />. - It dispatches the
changeevent.
But what about February 31st?
This is where the browser saves us again. If I manually construct the string 2024-02-31 and set it on the hidden input, the browser's native validation kicks in. <input type="date"> knows that February 31st doesn't exist. It marks the field as invalid.
I don't have to write leap year logic. I don't have to import date-fns just to check if a day is valid. I just pass the string to the browser, and if the browser rejects it, my CSS :user-invalid styles light up.
The Mobile Bonus
There is one more huge benefit to this approach, specifically for the DateInput. If you use a custom JavaScript date picker, you are forcing mobile users to tap tiny little <div>s on a tiny calendar. It's miserable. But because we are using <input type="date"> under the hood, we can choose to expose that to mobile users.
- On desktop, we show our fancy split inputs.
- On mobile, we can show the native date picker.
Because the data format is standard (ISO string), we can swap the UI without changing the logic. The browser handles the complex touch interactions, the scrolling pickers, and the localization. We just handle the value.
The Payoff: Styling with :has()
I mentioned getting CSS powers for free. This is where it gets fun.
Because our hidden input is inside our wrapper, and because it receives the browser's native validation state (:invalid, :user-invalid), we can style our entire custom component based on the hidden input's health. No more passing isError props down three levels. No more className={cx(styles.input, error && styles.error)}.
Just pure CSS:
/* When the hidden input is invalid... */
.wrapper:has(input:user-invalid) {
border-color: red;
animation: shake 0.2s;
}
/* When the hidden input is required but empty... */
.wrapper:has(input:placeholder-shown:required) .label::after {
content: "*";
color: red;
}
We are letting the browser manage the state of the UI. The React component just renders the structure; the CSS reacts to the truth of the DOM. (Note: :has() is now supported in all major browsers as of late 2023).
Conclusion
I stopped trying to build "Form Components." I started building "Input Wrappers." It sounds like a semantic difference, but it changed everything. By respecting the native <input> as the source of truth, I got so much for free.
- I got
FormDatasupport. - I got native validation.
- I got Server Action compatibility.
- I got CSS selectors like
:has(:user-invalid)to style my custom UI when the hidden input is unhappy.
It’s a bit of a magic trick. It’s a bit of a lie. But if it submits like an input, validates like an input, and focuses like an input...
It’s an input. Don't build zombies. Build inputs.