In React, component controls you!
Tags: react, javascript, webdev, forms
Published: 2020-10-20 21:34:47

For anyone working with React and forms (or individual inputs) the concept of [Controlled](https://reactjs.org/docs/forms.html#controlled-components) vs. [Uncontrolled](https://reactjs.org/docs/uncontrolled-components.html) 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](https://i.redd.it/qthto7qfczgx.jpg)

## 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](https://github.com/facebook/react/issues/10135).

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:

{% codesandbox on-change-blocked-53xd8 %}

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:

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

```

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

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

 <NumberInput onChange={onNumberChange} />
```

However, our __onNumberChange__ isn't called when we click the up or down buttons despite us using [dispatchEvent](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/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](https://github.com/facebook/react/blob/480626a9e920d5e04194c793a828318102ea4ff4/packages/react-dom/src/events/plugins/ChangeEventPlugin.js#L240).

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

```

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](https://media.giphy.com/media/29IalLLWizqz8SViU1/giphy.gif)


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](https://github.com/facebook/react/blob/e706721490e50d0bd6af2cd933dbf857fd8b61ed/packages/react-dom/src/client/inputValueTracking.js) file.

It [compares](https://github.com/facebook/react/blob/e706721490e50d0bd6af2cd933dbf857fd8b61ed/packages/react-dom/src/client/inputValueTracking.js#L129) 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](https://dev-to-uploads.s3.amazonaws.com/i/tfmctxa1h6wgxuiwf9hi.png)

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).

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


## 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:

```javascript

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

  //...
});
```

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](https://github.com/facebook/react/issues/10135) (also: [11488](https://github.com/facebook/react/issues/11488)) with React. But also a closed one. Meaning, it doesn't seem like it will be solved anytime soon. 
[19846](https://github.com/facebook/react/issues/19846) is a more recent one and still open but I wouldn't hold my breath. 

Before I show how we at [@Cloudinary](https://cloudinary.com) live with it, I'll show a couple of other workarounds:

### setNativeValue workaround

{% github https://github.com/facebook/react/issues/10135#issuecomment-401496776 %}

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](https://www.npmjs.com/package/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: 

```javascript
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...
});

```

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](https://reactjs.org/docs/events.html).

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:

{% codesandbox react-change-event-solution-m6vbm %}

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