Access to element with ID
Saturday 06/08/2022
·5 min readEvery HTML element with an id attribute is automatically exposed as a property on the global window object — <button id="submit"> makes window.submit (and bare submit) reference that element. This is a real browser behavior, defined in the HTML spec, and it works in every browser. It's also a foot-gun: it pollutes the global namespace, collides with framework variables, and breaks the moment you wrap your code in any kind of module or strict context. Below: how it works, when it bites, and the three patterns that should replace it.
The behavior, in code
<!DOCTYPE html>
<html>
<body>
<button id="myButton">Click me</button>
<script>
console.log(myButton)
// logs: <button id="myButton">Click me</button>
myButton.addEventListener('click', () => console.log('clicked'))
</script>
</body>
</html>
No document.getElementById, no querySelector — myButton is just there as a global. This is called "named element access" or "named property access on the window object" and it's specified in the WHATWG HTML standard.
The same applies to name attributes on form elements:
<form>
<input name="email">
</form>
<script>
console.log(email) // also works for name attributes
</script>
Why this exists
The behavior is a legacy from the early web (1995–2000) when many pages were written by people who'd never used a programming language before. Auto-creating globals from IDs made it possible to write "interactive" HTML without understanding the DOM API. Browsers couldn't remove the feature later because too many pages relied on it.
The HTML spec formalized it under the named property access rules. Modern browsers all implement it. It will not be deprecated in your lifetime.
Why it's a foot-gun anyway
1. Global namespace pollution
Every ID becomes a global. A page with 50 IDs has 50 named globals you didn't declare. They override variables you might later define:
<input id="config">
<script>
const config = { theme: 'dark' }
// SyntaxError: Identifier 'config' has already been declared
</script>
In non-const cases (using var), the global named-property wins silently — your var config ends up referencing the input element, not your config object. This is a real debugging nightmare.
2. Module scope doesn't help much
You'd think <script type="module"> would scope you off the global. It scopes your own variables, but the named globals are still on window:
<button id="myButton">Click</button>
<script type="module">
console.log(myButton) // still works — it's window.myButton
console.log(window.myButton) // explicitly true
</script>
The only way out is to never write code that depends on this behavior, ever.
3. Frameworks break it
In React, Vue, Svelte, or any framework where IDs are dynamic (generated by the renderer), window.myButton works inconsistently — sometimes the element exists, sometimes it doesn't, depending on render timing. Code that relied on the global access pattern breaks the moment you add a framework.
4. TypeScript can't see them
TypeScript has no way to know what HTML elements exist on the page. window.myButton is unknown at best, any at worst, and your editor offers no autocomplete. The whole point of TypeScript is undermined.
What to use instead
document.getElementById
The explicit, framework-free way:
const button = document.getElementById('myButton')
if (button) {
button.addEventListener('click', () => console.log('clicked'))
}
The if (button) guard matters — getElementById returns null if the element doesn't exist, and TypeScript will force you to acknowledge this.
document.querySelector
More flexible because it accepts any CSS selector:
const button = document.querySelector('#myButton')
const firstButton = document.querySelector('button')
const allInputs = document.querySelectorAll('input[required]')
Slightly slower than getElementById (because the engine has to parse the selector), but the difference is invisible for any UI you'd write.
Framework refs
In React, use useRef:
function MyComponent() {
const buttonRef = useRef(null)
return <button ref={buttonRef}>Click me</button>
}
In Vue, ref (template ref). In Svelte, bind:this. Every framework has its idiom for "give me a handle to this DOM node" — use the idiom, not the IDs.
Edge cases worth knowing
- Numeric IDs aren't valid identifiers.
<div id="1">doesn't createwindow['1']because identifiers can't start with a digit. You can still access viawindow['1']ordocument.getElementById('1'). - IDs with hyphens or special characters also aren't valid identifiers.
<div id="my-button">doesn't createwindow.my-button(that would be parsed as subtraction). You can access viawindow['my-button']. - Duplicate IDs are a spec violation but browsers don't enforce it.
window.foobecomes an HTMLCollection of all elements withid="foo", which is rarely what you want. - The behavior applies to forms and form controls even without an
id— thenameattribute on inputs creates similar globals on the form element.
TL;DR
| Pattern | When |
|---|---|
| Bare ID as a variable | Never — even though it works |
| document.getElementById | Plain HTML/JS, no framework |
| document.querySelector | Plain HTML/JS with flexible selectors |
| Framework refs (useRef, ref) | Any framework |
FAQ
Why does id create a global variable in JavaScript?
It's a legacy feature of HTML named "named element access," dating from the late 1990s. Browsers can't remove it because too many existing pages rely on it. Modern code should use document.getElementById or framework refs instead.
Does this work in <script type="module">?
The named global still exists on window — modules don't shield you from it. But your module-local variables can't shadow it, so the behavior is slightly less hazardous. Still, don't rely on it.
Does ID-based access work in React?
It works in the sense that the DOM element is reachable via window, but the timing is unreliable because React controls when elements mount and unmount. Use useRef instead — it's the idiomatic React pattern.
What about name attributes on form elements?
The same auto-globaling applies to elements with name attributes inside forms — the form itself exposes named properties for each input. As with IDs, it works but it's a foot-gun. Use formElement.elements.namedItem('fieldName') or controlled-component patterns instead.