HTML with JavaScript
JavaScript does not act on your HTML file. It acts on the DOM - the tree the parser built from it - which is why the Elements panel and your source sometimes disagree, and why a script that runs too early finds nothing.
This lesson is the join: how to load a script, how it finds things, and the two habits that keep the join safe.
Loading a script
<head>
<meta charset="utf-8">
<title>Loading a script</title>
<script src="/app.js" defer></script>
</head>
<body>
<h1>The script runs after this exists</h1>
</body>
Open this example in the editor and change it. Nothing you type is saved.
defer is the right default, and it solves two problems at once. The download starts immediately, in parallel with parsing, so nothing is blocked. And execution waits until the document has been parsed, so the script can rely on every element existing.
Without it, a script in the head runs before the body is parsed, so document.querySelector('h1') returns null - which is the single most common "my JavaScript does nothing" bug.
async is for genuinely independent scripts such as analytics; it executes the moment it arrives, in no guaranteed order. type="module" is deferred automatically.
Finding elements
<main>
<h1 id="title">Course notes</h1>
<ul class="course-list">
<li class="course" data-course-id="html-101">HTML</li>
<li class="course" data-course-id="css-101">CSS</li>
</ul>
<button type="button" id="refresh">Refresh</button>
</main>
<script>
var title = document.querySelector('#title');
var courses = document.querySelectorAll('.course');
var first = document.querySelector('[data-course-id="html-101"]');
var refresh = document.getElementById('refresh');
</script>
Open this example in the editor and change it. Nothing you type is saved.
querySelector takes any CSS selector and returns the first match; querySelectorAll returns all of them. getElementById is the fast path for an id.
The markup decision here is what to select on. An id is stable and unique, and right for one known element. A data- attribute is explicit about being a hook for script - which means a designer renaming a class cannot break your code. Selecting on a class that exists for styling is the fragile option, because the two purposes then move independently.
Events
<form id="search" action="/search" method="get">
<label for="q">Search courses</label>
<input id="q" name="q" type="search" required>
<button type="submit">Search</button>
</form>
<p id="status" role="status"></p>
<script>
var form = document.getElementById('search');
var status = document.getElementById('status');
form.addEventListener('submit', function (event) {
event.preventDefault();
status.textContent = 'Searching for ' + form.elements.q.value;
});
</script>
Open this example in the editor and change it. Nothing you type is saved.
addEventListener attaches a handler. event.preventDefault() stops the default behaviour - here, navigating away - which is what turns a working form into one the page handles itself.
Three things in that example are markup decisions rather than script ones. The form works without the script: remove it and the browser submits to /search, which is progressive enhancement in two lines. The input is required, so the browser still validates. And the status paragraph has role="status", so its text is announced when it changes - a plain p would update silently.
Note also form.elements.q - a form exposes its controls by name, which is more robust than a separate selector because it is the same name the server sees.
textContent, not innerHTML
The most important line in this lesson.
<p>Results for: <span id="echo"></span></p>
<script>
var term = new URLSearchParams(location.search).get('q') || '';
document.getElementById('echo').textContent = term;
</script>
Open this example in the editor and change it. Nothing you type is saved.
textContent sets text. Whatever the string contains - angle brackets, a script tag, an event handler - it becomes visible characters and nothing is parsed as markup.
innerHTML parses. Assigning a string that came from a URL, a form, an API or a database means handing the parser content you did not write, and if any of it is markup, the browser builds it.
So: textContent unless you are certain, and when you do need structure, build it from elements rather than from a string.
<ul id="courses"></ul>
<script>
var courses = [
{ id: 'html-101', title: 'HTML' },
{ id: 'css-101', title: 'CSS' }
];
var list = document.getElementById('courses');
courses.forEach(function (course) {
var item = document.createElement('li');
var link = document.createElement('a');
link.href = '/courses/' + encodeURIComponent(course.id);
link.textContent = course.title;
item.appendChild(link);
list.appendChild(item);
});
</script>
Open this example in the editor and change it. Nothing you type is saved.
Longer than one innerHTML assignment, and it cannot produce an injection: every value goes in as text or as an encoded URL component.
Enhance, do not replace
The order to build in is: make it work with HTML alone, then add script.
<form action="/subscribe" method="post" id="subscribe">
<p>
<label for="sub-email">Email</label>
<input id="sub-email" name="email" type="email" required
autocomplete="email">
</p>
<button type="submit">Subscribe</button>
<p id="sub-status" role="status"></p>
</form>
<script>
var form = document.getElementById('subscribe');
var out = document.getElementById('sub-status');
form.addEventListener('submit', function (event) {
event.preventDefault();
out.textContent = 'Subscribing ' + form.elements.email.value;
});
</script>
Open this example in the editor and change it. Nothing you type is saved.
Delete the script and the form still works: it posts to /subscribe, the browser still validates the address, and the person is still subscribed. The script makes it feel instant, which is an improvement on something that already worked rather than the only thing that works.
That matters more often than it sounds. Scripts fail to arrive on bad connections, are blocked by corporate proxies, and error on one browser out of five. A form built script-first is broken in all of those cases; this one is merely slower.
Keep the state in the markup
When a script changes what something is, the markup has to say so - or the change is visible only to people looking at the screen.
<button type="button" id="toggle" aria-expanded="false" aria-controls="panel">
Show filters
</button>
<div id="panel" hidden>
<p>The filter controls.</p>
</div>
<script>
var toggle = document.getElementById('toggle');
var panel = document.getElementById('panel');
toggle.addEventListener('click', function () {
var open = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
toggle.textContent = open ? 'Show filters' : 'Hide filters';
});
</script>
Open this example in the editor and change it. Nothing you type is saved.
aria-expanded is updated alongside the visual change. A toggle that animates perfectly and never updates that attribute is announced as collapsed forever.
And the element is a real button: focusable, activated by Enter and Space, announced as a button. A div with a click handler is none of those, and fixing that takes tabindex, role and two key handlers to get back to where button started.
Running at the right moment
Three timings, and choosing wrongly is the other common "it does nothing" bug.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Timing</title>
<script src="/early.js"></script>
<script src="/app.js" defer></script>
</head>
<body>
<h1>Three moments</h1>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelector('h1').textContent = 'The tree is built';
});
window.addEventListener('load', function () {
document.title = 'Images and stylesheets have arrived too';
});
</script>
</body>
</html>
Open this example in the editor and change it. Nothing you type is saved.
Immediately - a plain script runs where it sits, so it sees only what is above it.
DOMContentLoaded - the tree is complete. This is the moment most code wants, and it is also exactly when a defer script runs, which is why defer plus top-level code needs no listener at all.
load - everything has arrived, including images and stylesheets. Only needed when you must measure something whose size depends on an image.
Practice task
Build a page with a search form that a script intercepts and echoes the term back into the page - first with innerHTML, then with textContent. Submit <img src=x onerror=alert(1)> to each version. One shows an alert; the other shows the text. Then remove the script entirely and confirm the form still submits, because that is the version that works when the script fails to load.