If you have ever tried to script a React form — a test helper, a browser extension, a bookmarklet, an onboarding demo — you have probably hit this:
document.querySelector('#email').value = 'test@example.com';
The field shows the text. It looks filled. Then you submit, and React sends an empty string. Or the "Next" button stays disabled. Or the value vanishes the moment anything else on the page re-renders.
I ran into this repeatedly while building a form-filling extension, and the fix is not obvious from React's docs. Here is what is actually happening.
React keeps its own copy of the value
React attaches a value tracker to every controlled input. It is an internal object stored on the DOM node under a property React owns, and its only job is to remember what React last saw in that field.
When a real user types, two things happen:
- The browser updates
input.value - The browser fires an
inputevent
React's synthetic event system catches that event, compares the node's current value against the tracker's remembered value, sees they differ, and therefore concludes something changed and calls your onChange.
That comparison is the whole story. React does not ask "did the value change?" — it asks "does the DOM disagree with what I remember?"
Why direct assignment breaks it
HTMLInputElement.prototype.value is an accessor property with a setter. React replaces that setter on the individual node with its own version, one that updates the tracker as it writes.
So when you do input.value = 'x', you go through React's patched setter. It writes the value and updates the tracker to match. The DOM and the tracker now agree.
Then you dispatch an input event, React compares the two, finds them identical, and concludes nothing changed. onChange never fires. React's state still holds the old value, and the next render wipes your text away.
You did not fail to notify React. You notified React and React decided you were lying.
The fix: bypass the patched setter
You need to write the value without going through React's setter, so the tracker stays stale and the comparison detects a difference:
function setNativeValue(el, value) {
const proto = el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set is the browser's original setter, untouched. Calling it with .call(el, value) writes straight to the DOM, leaving React's tracker holding the old value. Now the comparison fails, React believes you, and onChange fires.
Note the prototype check. <textarea> has its own prototype with its own value setter, and using the input one on a textarea throws.
Three more things that bite
<select> needs change, not input. Select elements notify on change. Dispatch input at a <select> and nothing happens. Checkboxes and radios need click() or a change event, not a value write at all.
Some libraries want the full keyboard sequence. A handful of masked-input and autocomplete components listen for keydown/keyup rather than input. If the native-setter approach alone does not stick, dispatching keydown, input, keyup in order usually does.
Vue 3 is easier, Angular is different. Vue's v-model listens for plain input events, so the ordinary path works. Angular's ControlValueAccessor also listens for input on most controls, but reactive forms sometimes need a blur afterwards to run validators — otherwise the field is filled and still marked untouched.
Verify the write
This is the part I would most encourage you to steal, because it changed how much time I spent debugging.
After writing a value, read it back:
setNativeValue(el, value);
// let the framework's microtask queue drain
await Promise.resolve();
if (el.value !== value) {
// the framework rejected or transformed the input
}
If the value came back different, the page's framework rejected it — a mask reformatted it, a validator cleared it, a controlled component overwrote it on re-render. That is a completely different failure from "the selector did not match", and it needs a completely different fix.
Reporting those two cases separately turned my most common bug report from "it doesn't work" into "your app rejected this input, here is what it became" — which is actionable.
Shadow DOM: the other half of the problem
document.querySelectorAll('input') does not see inside shadow roots. A lot of modern component libraries put their real <input> inside one, so a script that works fine on a plain HTML page finds nothing at all on a component-library page.
Open shadow roots you can walk:
function* deepInputs(root = document) {
for (const el of root.querySelectorAll('*')) {
if (el.matches('input, textarea, select')) yield el;
if (el.shadowRoot) yield* deepInputs(el.shadowRoot); // nested roots too
}
}
Shadow roots nest, so this has to recurse — a component inside a component inside a component is common, and a single-level check misses it.
Closed shadow roots you cannot reach, at all. element.shadowRoot returns null when the root was created with { mode: 'closed' }, and there is no workaround from page or extension context. This is by design. The honest thing is to detect the likely case and say so rather than silently filling nothing: a custom element with no light-DOM children and no accessible shadowRoot is probably a closed root, and telling the user that is far better than a silent no-op.
Same-origin iframes work through iframe.contentDocument. Cross-origin iframes do not, and no amount of cleverness changes that — you need an explicit host permission for that origin, which is a real cost to weigh rather than something to engineer around.
Do not fill everything you find
The last thing, and the one I got wrong first: a script that fills every field it can find is worse than one that fills nothing.
Skip these, always:
-
disabledandreadonlyfields - Anything hidden —
type="hidden",display: none, zero-size,visibility: hidden -
CSRF tokens — usually a hidden input with
token,csrforauthenticityin the name. Overwriting one breaks the submit in a way that is genuinely hard to diagnose. - CAPTCHA fields — never touch them
-
<input type="file">— you cannot set it programmatically for good security reasons, and trying throws
I ended up writing more test fixtures for the must-not-fill cases than for the fill cases, and that ratio turned out to be right.
Test it against adversarial fixtures, not happy paths
Every one of the failures above is invisible on a simple form and obvious on a real one. So the fixtures worth writing are the nasty ones: a React controlled input, an open shadow root, a nested shadow root, a same-origin iframe, a field whose only signal is an autocomplete token, a field with no signals at all, an input with dots in its name, a CAPTCHA, a hidden CSRF token.
Mine runs 56 fixtures and grades 59 cases — the extra three are suite-level invariants like "one report row per discovered field" and "widget selections are deterministic". Current state is 48 passed, 0 failed, 11 correctly skipped, where the 11 skips are the guard cases that should be refused.
The number that matters there is the 11, not the 48.
I packaged all of this into a Chrome extension called FormForge — it fills forms with realistic test data and ships that self-test page so you can run the suite in your own browser build. It is free with no daily limit and no account, and it runs entirely locally.
But the native-setter trick above is the useful part whether or not you ever install anything. It is the single fix that solves the most common form-scripting bug in React, and it took me far too long to find.
If you know a form that breaks any of this, I would genuinely like to hear about it.
Top comments (0)