Back to All Posts

Building a Two-Way Data Binding Hook for Form Inputs in React

Two-way form input binding is a popular feature offered by JavaScript frameworks like Vue and Svelte. I took a minute to explore what the React version of it might look like. I don't hate the result.

A few days ago, I came across a tweet semi-lamenting the fact that React doesn't have automatic input binding like Vue or Svelte. If you're unfamiliar, the feature allows you to automatically update a piece of state whenever an input's value changes, just by adding a particular attribute. Here's how it looks in Vue, using v-model.

	<input v-model="text" />

	Your Text: {{ text }}

export default {
	data() {
		return {
			text: 'initial text',

Its non-existence in React really doesn't irk me that much, but I became interested in the idea when others started dreaming of an how an implementation might look in other frameworks. For example, this Preact version from Marvin Hagemeister using signals looks really slick.

React doesn't have first-class signals (yet), so I was left wondering what the "React" way of building something like that might entail and couldn't resist dabbling. I took to StackBlitz and ended up kinda liking how it turned out. Let's walk through it a bit.

A Dishonorary Mention: Using HTML Attributes

Inspired by Marvin's Preact example, I was optimistic about figuring out a non-signals way of making a simple HTML attribute approach work. I was aiming for something like this, wrapped up into a custom hook. Note the data-bind attribute simply being set to the name of the state I want to bind:

export default function App() {
  const [name] = useBoundState("");

  return (
      <input bind-state="name" />
      Name: {name}

It didn't go well. We're dealing with plain, old attributes – not props – and so the only value type you can pass is a string. I couldn't pass around a reference to a state updater to trigger when an input is used.

I made the approach sorta "work" using a Map() and directly querying the DOM via .querySelectorAll(), but it's risky and clunky. No way I'm dropping the code here. If you want to see it, check out StackBlitz. And if you know of a way to make it work, drop a comment.

Refs to the Rescue

A way more satisfying effort was made with refs, React's native way of storing persistent references to objects (such as DOM nodes). Here's the API:

function SomeInput() {
  const [name, nameBinding] = useBoundState('');

  return (
      <input ref={nameBinding} />
      <h3>Your Name:</h3> {name}

I like it because it feels very much like any other useState() hook. Pass in the initial value of the input, and you get back a tuple containing the state variable and a binding you'll attach to the input itself (as you'll see in the code below, that tuple also returns the state updater itself if it's needed).

That binding is just a ref. There's no magic going on. And the hook itself is pretty simple too.

function useBoundState(initialValue) {
	// Create ref for input element.
	const stateRef = useRef();
	// Create state for storing value.
	const [stateName, setState] = useState(initialValue);

	useEffect(() => {
		function callback(e) {
		// Listen for input interactions.
		stateRef.current?.addEventListener('input', callback);

		// Clean up! 
		return () => {
			stateRef.current?.removeEventListener('input', callback);

	// I'm returning the state updater in there, 
	// in case it's ever needed for making direct updates.
	return [stateName, stateRef, setState];

Here's the verbal breakdown: before that ref is returned, it's attached with an event listener to respond to any input events triggered by putting text in the input. Whenever that event fires, that state is updated with the current value.

It'll work for any element that fires a input event and has a value property on it, so we could use it for an entire form (and it could be expanded to support other non-text inputs as well):

function SomeForm() {
	const [textareaValue, textareaBinding] = useBoundState('');
	const [inputValue, inputBinding] = useBoundState('');

  	return (
			<textarea ref={textareaBinding}></textarea>
			textarea value: {textareaValue}

			<input type="text" ref={inputBinding} />
			input value: {inputValue}

It's easy to make configurable too. For example, maybe you'd like to update on keydown instead of input. We can enable that by introducing a second "options" parameter.

// `event` is now configurable:
function useBoundState(initialValue, { event = 'input' } = {}) {
	// ... other code goes here.
	useEffect(() => {
    	// Listener attached here...
		stateRef.current?.addEventListener(event, callback);

		// ... and cleaned  up here.
		return () => {
			stateRef.current?.removeEventListener(event, callback);
	// ... other code goes here.

You get the idea. Through using a couple features of React already available, it doesn't take much to capture some of the magic the binding magic other frameworks offer.

Making It Two-Way

But there is one more important piece to this: the binding needs to work both ways. State should be updated when input values change, and input values should change when state is updated. Placing another useEffect()hook in our useBoundState() hooks makes that requirement pretty simple:

useEffect(() => {
	if (!stateRef.current) return;

	// Update input value whenever state changes.
	stateRef.current.value = stateName;
}, [stateName]);

It's comforting to know this won't cause any unwelcome side effects. Updating an input's value arttribute directly doesn't trigger a new input event, so nothing crazy will happen (like an infinite state-updating loop).

If you'd like to get deep in the performance weeds here, you could spend some time figuring out how to prevent an attribute update only if we know the state change didn't come from our event listener's callback, during which we set the state ourselves. But I think that's a waste of time. It's all happening fast enough, and the amount of complexity that "optimization" would introduce isn't worth the effort.

The Final Product

With all those adjustments in place, here's the hook in entirety:

Would I actually use this?

I suppose that's the most important question. For most use cases involving forms, probably not. The browser is already really good at handling form state out of the box, especially with the vanilla JavaScript APIs it offers. For example, the native FormData() works great for directly accessing submitted input values, or sending the entire payload along to an endpoint:

return (
	<form onSubmit={(e) => {
		const formData = new FormData(;

		// Get input values.
		const name = formData.get('name');

		// Send it somewhere.
		const response = await fetch("/some-place", {
			method: "POST",
			body: formData,
		<input type="text" name="name" />
		<input type="submit" value="submit" />

That said, I can see it being useful in other scenarios, like when it's important to load every keystroke into state and you can't afford to wait until submission to do so. You get the idea. I know the use cases exist.

But even if I don't find myself reaching for it anytime soon, it was a fun exercise. Hope you agree. If you've got another approach you've baked yourself, I'd love to see it.

Alex MacArthur is a software engineer working for Dave Ramsey in Nashville-ish, TN.
Soli Deo gloria.

Get irregular emails about new posts or projects.

No spam. Unsubscribe whenever.
Leave a Free Comment

  • frgdfgdfg


  • Wei Guan

    love it!

    I'd like to ask why it's not written in this way.

    useEffect(() => {
    function callback(e) {

    stateRef.current?.addEventListener(event, callback);

    return () => {
      stateRef.current?.removeEventListener(event, callback);

    }, [stateRef, setState]);

  • Joscha

    Love it, thanks for sharing!

    Is there a reason you're not adding an empty dependency array to the effect handling the event listener?

  • d

    Pretty cool!

    I was wondering if it's easier to accept the element tag as an argument to hook and returning a bound element directly. This also let's the hook figure out the right event to listen to internally.

    const [name, Input] = useBound("input", "");

    return <>
    <Input />