DEV Community

Alex MacArthur
Alex MacArthur

Posted on • Originally published at macarthur.me on

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

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.

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

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

<script>
export default {
    data() {
        return {
            text: 'initial text',
        };
    },
};
</script>
Enter fullscreen mode Exit fullscreen mode

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 (
    <div>
      <input bind-state="name" />

      Name: {name}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
rafde profile image
Rafael De Leon
   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);
        };
    });
Enter fullscreen mode Exit fullscreen mode

This will cause the useEffect will cause the input to add and remove the listener for every component render and re-render. Is this intentional? If not, I think you need to

   const [ hasElementMounted, setHasElementMounted] = useState(false);
   useEffect(() => {
        if (hasElementMounted || !stateRef.current) {
          return;
        }


        function callback(e) {
            setState(e.target.value);
        }

        // Listen for input interactions.
        stateRef.current?.addEventListener('input', callback);
        setHasElementMounted(true);
        // Clean up! 
        return () => {
            stateRef.current?.removeEventListener('input', callback);
        };
    }, [
        hasElementMounted
    ]);
Enter fullscreen mode Exit fullscreen mode

or something like this

Collapse
 
alexmacarthur profile image
Alex MacArthur

Yep, that’s a great point. It’ll attach & detach that listener on every render/update. I went that route mainly out of code simplicity, but I do think it’s prudent to only do that work when it’s needed. I like your approach to the problem.