loading...
Cover image for In React, component controls you!

In React, component controls you!

poeticgeek profile image Yoav Niran ・5 min read

For anyone working with React and forms (or individual inputs) the concept of Controlled vs. Uncontrolled components should be familiar.

Yet, a much less known fact about React is that it actively tracks and controls the value for such input elements (ex: input, textarea, etc.).

A recent (and frustrating) brush with React's internal management of the input value got me diving into the source code. I came back out to tell you all about it.

Alt Text

What happened?

In a sentence: React will stifle change events (even custom ones) when it thinks they shouldn't be raised.

If that sounds strange, believe me, I found it unbelievable until I ran into this issue.

If you don't believe me, check out the following sandbox. Click the up/down buttons. Notice that nothing is written out to the console:

First, shame on you for not believing me!
Second, let's take a look at what happens here.

The code in the sandbox renders a (controlled) number input with additional UI to increase/decrease the value. However, no change event is fired at this point.

So... We raise a custom input/change event with the expected result being that our onChange handler will be called:

 const onUp = useCallback(() => {
    setValue(value + 1);
    internalRef.current.dispatchEvent(new Event("input", { bubbles: true }));
  }, [internalRef, value]);

Enter fullscreen mode Exit fullscreen mode

The code outside the component registers for the change event using onChange:

 const onNumberChange = useCallback((e) => {
    console.log("NUMBER CHANGE ! ", e.target.value);
  }, []);

 <NumberInput onChange={onNumberChange} />
Enter fullscreen mode Exit fullscreen mode

However, our onNumberChange isn't called when we click the up or down buttons despite us using dispatchEvent. While typing into the input directly does trigger our handler.

Closer look

To understand why this is happening. We need to look into the react-dom code. specifically, here.

function getTargetInstForInputOrChangeEvent(
  domEventName: DOMEventName,
  targetInst,
) {
  if (domEventName === 'input' || domEventName === 'change') {
    return getInstIfValueChanged(targetInst);
  }
}

Enter fullscreen mode Exit fullscreen mode

It's easy to see that React will only return the element (target) if its value changed. Our problem manifests because as far as React is concerned, the value didn't change when we dispatch our event. And just like that, we're stuck. React won't let us use a custom change event.

blocked

Before we discuss solutions. Let's take a look at how React determines whether the value changed or not.

React uses a 'valueTracker', conveniently found in the aptly named: inputValueTracking.js file.

It compares the value stored in the tracker with the value in the DOM. If they are the same, the event simply won't trigger. Simple as that.

You can actually locate it in the DOM, for every input element:

Alt Text

The value tracking mechanism is subtle but invasive. It takes over the getter and setter of the element's value property (value, checked). It's therefore cumbersome to circumvent (as discussed below).

Object.defineProperty(node, valueField, {
    configurable: true,
    get: function() {
      return get.call(this);
    },
    set: function(value) {
      currentValue = '' + value;
      set.call(this, value);
    },
  });
Enter fullscreen mode Exit fullscreen mode

DOM (not) Solution

One way to capture the event is by using addEventListener.
You can attach your own handler for the change event and forward it to the onChange handler:


const NumberInput = forwardRef((props, ref) => {
  const { onChange } = props;

  //...
  useEffect(() => {
    internalRef.current.addEventListener("input", (e) => {
      onChange(e);
    });
  }, [onChange]);

  //...
});
Enter fullscreen mode Exit fullscreen mode

But! This doesn't work correctly since the event is fired before React had a chance to re-render due to state change and therefore set the new value in the input.

Our onChange handler is called, but extracting the value with e.target.value will only give us the previous value rather than the most up-to-date one.

Even if you get this to work. By, for example, setting the value manually (yikes!), this is far from pretty. We didn't board the React-train to do DOM event handling, am I right?!

Another issue is that it will most likely trigger all sorts of Typescript/Flow errors because the event types won't match.

Let's look at other ways to solve this.

React Solutions

As I mentioned above, this is a known issue (also: 11488) with React. But also a closed one. Meaning, it doesn't seem like it will be solved anytime soon.
19846 is a more recent one and still open but I wouldn't hold my breath.

Before I show how we at @Cloudinary live with it, I'll show a couple of other workarounds:

setNativeValue workaround

Comment for #10135

I'm not sure why I missed this or why this is, but it looks like there actually is a value setter on the prototype but there isn't always one on the element. So here's my adjusted version that works:

<!doctype html>
<html>

<body>
  <textarea></textarea>
  <script>
    const textarea = document.getElementsByTagName('textarea')[0]
    function setNativeValue(element, value) {
      const { set: valueSetter } = Object.getOwnPropertyDescriptor(element, 'value') || {}
      const prototype = Object.getPrototypeOf(element)
      const { set: prototypeValueSetter } = Object.getOwnPropertyDescriptor(prototype, 'value') || {}

      if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
        prototypeValueSetter.call(element, value)
      } else if (valueSetter) {
        valueSetter.call(element, value)
      } else {
        throw new Error('The given element does not have a value setter')
      }
    }
    setNativeValue(textarea, 'some text')
    textarea.dispatchEvent(new Event('input', { bubbles: true }))
  </script>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Good luck friends. Stay safe out there!

It's possible to circumvent React's circumvention. It ain't pretty but it's simple enough. You do need to add this workaround for each type of element you wish to override React's behavior, though. :(

react-trigger-change

Important! not really a solution as even its creator disclaims that it's not intended for production.

It can be helpful for E2E tests, however.

A simple workaround

The code below is a similar to what we ended up using:

const noOp = () => {};

const createEvent = (target, type = "custom") => ({
  target,
  currentTarget: target,
  preventDefault: noOp,
  isDefaultPrevented: () => false,
  stopPropagation: noOp,
  isPropagationStopped: () => false,
  persist: noOp,
  nativeEvent: null,
  bubbles: false,
  cancelable: false,
  defaultPrevented: false,
  eventPhase: 0,
  isTrusted: false,
  timeStamp: Date.now(),
  type
});

const NumberInput = forwardRef(({ defaultValue, onChange, ...props }, ref) => {

  //...

  const triggerChange = useCallback(
    (valueChange) => {
      const newValue = (value ? parseInt(value) : 0) + valueChange;
      setValue(newValue);
      onChange?.(createEvent(internalRef.current, "change"), newValue);
    },
    [onChange, value]
  );

  const onUp = useCallback(() => {
    triggerChange(1);
  }, [triggerChange]);

  const onDown = useCallback(() => {
    triggerChange(-1);
  }, [triggerChange]);

  const onInputChange = useCallback(
    (e, newValue) => {
      setValue(newValue);
      onChange?.(e, parseInt(newValue));
    },
    [onChange]
  );

  return (
    <NumberInputWrapper>
      <TextInput
        {...props}
        type="number"
        ref={internalRef}
        value={value}
        onChange={onInputChange}
      />
      <NumberButtonsContainer...
});

Enter fullscreen mode Exit fullscreen mode

Instead of working directly with the DOM, we mimic the change event and supply a dummy, yet close enough, event object. We add an internal onChange handler that updates the state and then pass the info to an external handler if one was supplied.

There's a little catch though. You may have noticed the new parameter added to the change handler. At Cloudinary, we're writing our own UI components and chose to supply all of our onChange handlers with both event and value parameters, rather than just the former.

This means we can bypass the issue of the event's target (ex: input) not holding the most up-to-date value yet.

The solution also plays nice with Typescript as the object createEvent() returns is the same shape as SyntheticEvent.

While the way we chose to solve the problem here doesn't feel all that clean, it still seems like the least hacky. At least to me. And most important - not tied tightly to React's internal mechanism, so should be easy to change if things improve in the future.

The full code sample for the solution we chose can be found here:

If you have another solution for this problem, please share in the comments.

Discussion

pic
Editor guide
Collapse
link2twenty profile image
Andrew Bone

I might be being a bit dumb but why do you need the event? If you're just interested in passing the changed value you can use useEffect, or am I missing something?

React.useEffect(() => {
  onChange(value);
}, [value, onChange])
Enter fullscreen mode Exit fullscreen mode
Collapse
poeticgeek profile image
Yoav Niran Author

Thats indeed a possibility. If you never need the event object then its OK to just expose the value. However, React's onChange does give you the event. And when writing low level components and trying to be as transparent as possible, we attempt to provide as many options to the consuming code.
It means that the "native" onChange event from React (ex: user types a value directly) will be able to pass the event object. While custom interactions (like the up/down buttons) wont and there will be inconsistency between these types.
This article shows a solution that aims to provide the most transparent and consistent experience for anyone consuming this type of component.

Collapse
link2twenty profile image
Andrew Bone

OK, I think I understand the problem now 😊

I think this custom hook will fix your issues.

import React from "react";

export default function (initalState, target) {
  const [state, setState] = React.useState(initalState);
  const [newState, setNewState] = React.useState(initalState);
  const event = React.useMemo(() => new Event("change", { bubbles: true }), []);

  const updateStates = React.useCallback((updated) => {
    setState(updated);
    setNewState(updated);
  }, []);

  React.useEffect(() => {
    if (state === newState) return;
    const { current } = target;

    if (!current) return;

    current.value = newState;
    const tracker = current._valueTracker;

    if (tracker) {
      tracker.setValue(state);
    }

    current.dispatchEvent(event);
  }, [state, newState, event, target]);

  return [state, updateStates, setNewState];
}
Enter fullscreen mode Exit fullscreen mode

You basically use this hook the same way you use React.useState but you have to pass in a ref target when you initialise it also it returns 3 items rather than 2, [state, setState, callStateChange].

That last function will change the value of the input and trigger an event for your onChange to pick up and deal with.

Here's a working example.

Unless I've totally misunderstood again πŸ˜…

Collapse
pengeszikra profile image
Peter Vivo

Maybe without DOM manipulation can solve this problem lot easier:

import React, { useState } from 'react';

const changeHandler = setter => ({target:{value}}) => setter(value);

export default () => {
  const [counter, setCounter] = useState(0);

  return (
    <pre>
      <input value = {counter} onChange={changeHandler(setCounter)}/>
      <button onClick={ _ => setCounter(v => +v ? +v + 1 : 1)}> + </button>
      <button onClick={ _ => setCounter(v => +v ? +v - 1 : -1)}> - </button>
    </pre>
  );
}
Enter fullscreen mode Exit fullscreen mode

when need lot of counters we can give value / set function (action) to each one:

import React, { useState } from 'react';

const changeHandler = setter => ({target:{value}}) => setter(_ => value);

const InputCounter = ({counter, setCounter}) => (
  <div>
    <input value = {counter} onChange={changeHandler(setCounter)}/>
    <button onClick={ _ => setCounter(v => +v ? +v + 1 :  1)}> + </button>
    <button onClick={ _ => setCounter(v => +v ? +v - 1 : -1)}> - </button>
  </div>
);

export default () => {
  const [counterList, setCounterList] = useState([1,2,3,4,'wrong starting value',7,'1e10','0xfdf']);

  const setIndexed = index => setter => setCounterList(
    list => list.map(
      (v, i) => i === index
        ? setter(v)
        : v
    )
  );

  return (
    <pre>
      {counterList.map(
        (counter, index) => (
          <InputCounter 
            counter={counter}
            key={index}
            setCounter={setIndexed(index)}
          />
        )
      )}
      {JSON.stringify(counterList ,null , 2)}
    </pre>
  )
}
Enter fullscreen mode Exit fullscreen mode

bonus: works with hexadecimal values written 0xABC format

Collapse
0916dhkim profile image
Donghyeon Kim

What about this?

  const onUp = () => setValue(value + 1);
  const onDown = () => setValue(value - 1);
  const onInputChange = (e, next) => setValue(next);

  useEffect(() => {
    onChange?.(createEvent(internalRef.current, "change"), value);
  }, [value]);
Enter fullscreen mode Exit fullscreen mode

Did I miss something?

Collapse
jwp profile image
John Peters

Nice post and congrats on the React internals dive.
I've always felt React to be highly opinionated almost as much as Angular.js.

This problem is as opinionated as a framework can become.

Even the new Angular is opinionated but much less than before.

I'm of the opinion that work like Svelte and LitHtml as well as web components are ready to overtake the big 3.

Whole entire frameworks are sunsetting due to new emerging standards making them less important.

I feel the closer to the Dom we get the better.

Collapse
jfbrennan profile image
Jordan Brennan

Ouch. This is the second issue I've seen where it's React getting in the way and maintainers close the issue unresolved.

I honestly don't understand why anyone uses React. Vue, Riot, Svelte all run circles around React

Collapse
jfbrennan profile image
Jordan Brennan

The tank image is hilarious btw

Collapse
devhammed profile image
Collapse
guitarino profile image
Kirill Shestakov

Awesome work. One of the reasons I use Preact is because it actually uses the native DOM behavior for events rather than creating abstractions like synthetic events.