Building a Two-Way Data Binding Hook for Form Inputs in React
Tags: react, javascript, forms, vue
Published: 2023-08-10 18:28:42

---
title: Building a Two-Way Data Binding Hook for Form Inputs in React
published: true
date: 2023-08-10 14:29:35 UTC
tags: react, JavaScript, forms, vue
canonical_url: https://macarthur.me/posts/binding-input-state-in-react
---

A few days ago, I came across [a tweet](https://twitter.com/saltyAom/status/1688247662280372224?ref=alex-macarthur) semi-lamenting the fact that React doesn't have automatic input binding like [Vue](https://vuejs.org/guide/essentials/forms.html?ref=alex-macarthur) or [Svelte](https://svelte.dev/tutorial/text-inputs?ref=alex-macarthur). 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`.

```
<template>
	<input v-model="text" />

	Your Text: {{ text }}
</template>

<script>
export default {
	data() {
		return {
			text: 'initial text',
		};
	},
};
</script>
```

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](https://twitter.com/marvinhagemeist/status/1688512714807607296?ref=alex-macarthur) 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](https://stackblitz.com/edit/stackblitz-starters-xntjyx?file=src%2FApp.js&ref=alex-macarthur). 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 (
    <div>
      <input bind-state="name" />
      
      Name: {name}
    </div>
  );
}
```

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](https://stackblitz.com/edit/stackblitz-starters-rxuw3o?file=src%2FApp.js&ref=alex-macarthur). 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) {
			setState(e.target.value);
		}
		
		// 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 (
		<form>
			<textarea ref={textareaBinding}></textarea>
			textarea value: {textareaValue}

			<input type="text" ref={inputBinding} />
			input value: {inputValue}
		</form>
	);
}
```

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:

https://stackblitz.com/edit/stackblitz-starters-xntjyx?file=src/App.js

## 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(e.target);

		// 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" />
	</form>
);
```

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 used yourself, I'd love to see it.