DEV Community

Cover image for How I Bypassed React's Synthetic Event System to Automate Form Filling
Muhammad Umer Mehboob
Muhammad Umer Mehboob

Posted on

How I Bypassed React's Synthetic Event System to Automate Form Filling

How I Bypassed React's Synthetic Event System to Automate Form Filling

I had a Chrome extension that needed to fill a job application form automatically. The form had 16 fields. I wrote what seemed like obvious code:

document.querySelector('input[name="firstName"]').value = "John";
Enter fullscreen mode Exit fullscreen mode

The field showed "John". Perfect.

Then I clicked Submit.

The form screamed back: "This field cannot be left blank."

I stared at the screen. The input clearly showed "John". The form was telling me it was empty. I refreshed, tried again, same thing. That moment of confusion is what sent me down a two hour rabbit hole into React internals I did not know existed.


Why el.value = "x" Does Nothing on React Inputs

Here is the thing most people do not know about React: it does not store form state in the DOM.

React maintains something called the fiber tree, which is its own internal data structure that lives completely separately from what you see in the browser. The DOM is basically just a visual output of that tree, not the source of truth.

When a real user types into an input, two things happen:

  1. The browser updates the DOM (so input.value changes)
  2. An input event fires, bubbles up through the DOM tree, React's listener at the document root catches it, reads the new value, and updates the fiber tree

When you set input.value = "John" from code, only the first thing happens. The event never fires. React's fiber state never gets updated. As far as React is concerned, you never typed anything.

So the form showed "John" because the DOM had "John". The validation failed because React's internal state still had an empty string. Two different sources of truth pointing to two different things.


The Fix That Looks Right But Isn't

After I figured out the event problem, my immediate instinct was this:

input.value = "John";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
Enter fullscreen mode Exit fullscreen mode

Dispatch the events manually. React picks them up. Done.

Except this does not actually work. Not on React 16 or anything after.

The reason took me a while to find. React does not listen for plain Event objects when tracking input changes. It specifically listens for InputEvent, which is a different interface that has extra properties like inputType and data. These tell React what kind of input action happened, whether the user typed, deleted, or pasted something.

When you dispatch new Event("input"), React receives something but it does not process it the way it processes a real user action. It is like sending a message in the wrong format. The message arrives but gets mostly ignored.

I only confirmed this by actually reading through parts of React's source code. The synthetic event system checks the constructor and specific properties on the native event before deciding what to do with it.


What Actually Works

There are three parts. All three need to be there. Missing any one of them causes a different problem.

Part 1: The Native Setter

React overrides the value property on HTMLInputElement. So when you write input.value = "x", you are calling React's version of that setter, which does React bookkeeping and not the same thing as a real browser input.

The fix is to get the original setter before React touched it:

const nativeSetter = Object.getOwnPropertyDescriptor(
  HTMLInputElement.prototype,
  "value"
)?.set;

nativeSetter.call(input, "John");
Enter fullscreen mode Exit fullscreen mode

Object.getOwnPropertyDescriptor pulls the original property descriptor from the prototype, the one the browser defined. Calling .set on it writes directly to the underlying DOM node, completely bypassing whatever React layered on top.

Part 2: The Right Event Type

Once the DOM value is correctly set, you need to notify React. But as I mentioned above, new Event("input") is not enough. You need InputEvent:

input.dispatchEvent(new InputEvent("input", {
  bubbles: true,
  cancelable: true,
  inputType: "insertText",
  data: "John"
}));

input.dispatchEvent(new Event("change", { bubbles: true }));
Enter fullscreen mode Exit fullscreen mode

Two things to note here. First, bubbles: true is not optional. React uses event delegation, meaning it puts one single listener on the document root rather than attaching listeners to every individual input. If your event does not bubble, it never reaches that root listener and React never sees it.

Second, the inputType: "insertText" and data: "John" properties are what tell React this was a genuine typing action. Without those, React gets the event but may not treat it as real user input.

Part 3: The Focus and Blur Cycle

This is the one most people miss and it caused me the most confusion.

React's validation logic only runs when a field has been "touched", meaning it received focus and then lost it (blur). If you programmatically set a value but never simulate that focus/blur cycle, the validation state never updates. The field stays in its initial untouched state.

input.focus();
// set value and dispatch events here
input.dispatchEvent(new FocusEvent("blur", { bubbles: true }));
input.blur();
Enter fullscreen mode Exit fullscreen mode

Put all three parts together and you get this function:

const _nativeSetter = Object.getOwnPropertyDescriptor(
  HTMLInputElement.prototype,
  "value"
)?.set;

function reactSet(el, value) {
  // tell React this field was interacted with
  el.focus();

  // write value via native setter, bypasses React's override
  if (_nativeSetter) _nativeSetter.call(el, value);
  else el.value = value;

  // InputEvent is required, plain Event does not work on React 16+
  el.dispatchEvent(new InputEvent("input", {
    bubbles: true,
    cancelable: true,
    inputType: "insertText",
    data: value
  }));
  el.dispatchEvent(new Event("change", { bubbles: true }));

  // trigger validation lifecycle
  el.dispatchEvent(new FocusEvent("blur", { bubbles: true }));
  el.blur();
}
Enter fullscreen mode Exit fullscreen mode

After this every field filled correctly. No more validation errors on any of the 16 inputs.


Does This Work on React 17 and 18 Too?

Yes. React's fiber architecture and its event delegation system have stayed the same since React 16. The core mechanism, one root listener wrapping native events in synthetic events and syncing to the fiber tree, has not changed across versions.

React 18 introduced concurrent mode which batches some state updates differently. In practice this function handles that correctly because we are dispatching synchronously and hitting the full event lifecycle in the right order.


The Part That Surprised Me

I expected the solution to be the interesting part of this problem. It was not.

The interesting part was realizing that this exact problem affects basically every developer tool that touches React forms.

React Testing Library's fireEvent does the same thing under the hood. Playwright and Cypress both have special handling specifically for React controlled inputs. Any browser automation tool that just sets element.value breaks on React forms and does so silently without any obvious error.

The common thread is this: React's state is not the DOM. The DOM is just what React decides to show you based on its internal state. If you want to change what React knows, you have to go through React's event system, or convince it that a real user did something.

This changes how I think about debugging React bugs generally. Next time a form field looks correct but fails validation or does not trigger the right handler, I check whether the event is actually reaching React, not just whether the DOM looks right.


Where This Came From

This came up while building a Chrome extension as part of a technical assessment. The task was to automate filling a job application form built on Eightfold AI's ATS platform.

Beyond the React input problem, the extension also had to handle Eightfold's custom dropdown components (they use <button role="option"> React components instead of native select elements), automate file uploads using the DataTransfer API, and deal with a subtle Chrome extension bug where the content script was being injected twice and crashing silently. Each of those is its own story.

Full source code is on GitHub if you want to look at how it all fits together: github.com/Umerheree/eightfold-autofill


Muhammad Umer Mehboob is a final-year CS student at COMSATS University Islamabad. He writes about JavaScript internals, browser APIs, and things that break in ways that make no sense until suddenly they do.

Top comments (0)