These days, if you are building a dynamic web app with JavaScript, you are likely using a front-end framework, like React, Angular, Vue, or Svelte. These frameworks provide abstractions on top of the native DOM APIs that ship with browsers to make it easier to create truly dynamic content.
However, like all abstractions, they come with their fair share of downsides. They might be a little bit slower than making raw DOM API calls; each of them requires browsers to download a bit of extra code just for the framework; sometimes the abstraction makes it hard to do exactly what you need to.
In this post, we'll throw out all of those frameworks and go back to the basics. We'll cover everything you need to know to create a dynamic website using just DOM APIs. And I'll include links to MDN Web Doc pages which talk about anything we don't cover.
What We'll Build
Using the Pokémon API, we'll create a page that lets you navigate through each of the Pokémon, showing an image and including back and forward buttons. If you aren't familiar with the Pokémon API, you can learn about it on its website.
We'll use a very simple HTML file that only has a link to a JavaScript file. Everything else will be created dynamically using JavaScript.
<!DOCTYPE html><html><head><title>Raw DOM API Pokedex</title><meta charset="UTF-8" /></head><body><script src="index.js"></script></body></html>
We'll fill out our index.js
file with the necessary code to call the Pokémon
API and create the DOM elements on the page.
Document
Before we get any further, let's talk about
document
.
document
is a global object, which means you can access it from any JavaScript
file loaded in the browser.
This is your window into the world of the browser DOM. It represents the root of
the webpage, and gives you access to any DOM element on the page using APIs like
document.getElementById
and
(document.querySelector
)[https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector].
document
also has properties which give you access to the head
and body
elements, which makes it possible to dynamically add stylesheets to the head
or content to the body
. Both of these are considered HTML Elements, which are
the building blocks of websites.
document
also gives you APIs to create new elements. Using
document.createElement
we can create an element that represents any HTML tag. Let's do that now to
create an element to wrap our entire app.
const app = document.createElement("div");
Our app
variable contains an HTMLDivElement, which represents that individual
DOM element.
HTMLElement
HTMLElement is
the base class which all DOM elements, such as head
, body
, and div
extend.
They all share several properties and methods, so let's dive into those really
quick.
There are three ways you can change the contents of an element. If the contents
are just text, you can set the innerText
property of the element.
app.innerText = "Hello there!";
A quick and dirty way of adding HTML content to an element is setting the
innerHTML
property. Note that this isn't particularly performant, and can open
you up to
cross-site scripting attacks
if you are inserting user-provided content. Make sure you sanitize whatever
content you put in to keep your users safe.
app.innerHTML = "<h1>Hello there!</h1>";
Finally, we can append an HTMLElement to another element using the
appendChild
method. This is what we will use most of the time as we create our web page.
This creates a tree structure, where each HTMLElement represents a node that has one parent and zero or more child nodes.
const header = document.createElement("h1");header.innerText = "Hello there!";app.appendChild(header);
If we need to put an element in a specific position on the page, we can use the
insertBefore
method. This method takes two parameters: the first is the new
node, and the second is a child of the node we are adding the child to.
Note that if the new node is already present on the parent node, the new node will be moved to the new position.
const menubar = document.createElement("nav");// Places the menubar element above the header elementapp.insertBefore(menubar, header);
Finally, if we need to get rid of an element, all we have to do is call the
remove
method on that element.
menubar.remove();
You can add and remove classes with the
classList
API.
Adding a class is done by calling app.classList.add('container')
; You can use
the remove
method to take off any classes. And you can see whether an element
has a class with the contains
method. Let's give our app
element a class.
app.classList.add("wrapper");
HTMLElements can be assigned an ID, which allows them to be accessed with
document.getElementById
and targeted with CSS ID selectors. ID's are assigned
using the id
property of the element.
app.id = "app";
If we need to find an element on the page, there are several methods we can use. We'll just talk about three of them.
document.getElementById
lets you grab any element by ID. In the HTML
specification, each ID should be unique on the page, which means an ID is only
ever assigned to one element. If the element we want has an ID, we can grab it
directly.
const app = document.getElementById('app`)
We can also take advantage of CSS selectors to get individual elements or lists
of elements using document.querySelector
and document.querySelectorAll
.
// This returns the first element to match the selectorconst pokeImage = document.querySelector("image.poke-image");// This returns a node list of all of the elements on the page that match this selector.const pokeTypes = document.querySelectorAll(".poke-type");
Before we get back to creating our Pokédex, lets cover one more important
document API. Suppose we had this HTML that we wanted to create using the
appendChild
DOM API.
<p>This is a <strong>water</strong> type Pokémon</p>
How do we put that strong
element in the middle of that text? For this, we'll
need one more document
method. document.createTextNode
lets you create DOM
nodes that only contain text without a tag. By appending Text nodes and HTML
elements in the correct order, we can recreate this.
const label = document.createElement("p");label.appendChild(document.createTextNode("This is a "));const pokeType = document.createElement("strong");pokeType.innerText = "water";label.appendChild(pokeType);label.appendChild(document.createTextNode("type Pokémon"));
With all of that out of the way, let's start building.
Fetching Pokémon
We'll use the fetch
API to get the very first Pokémon. As we fetch the
Pokémon, we'll show a "Loading..." indicator.
const baseURL = "https://pokeapi.co/api/v2/pokemon/";const app = document.createElement("div");document.body.appendChild(app);const loading = document.createElement("p");loading.innerText = "Loading...";loading.classList.add("loading");async function getPokemon(id) {const response = await fetch(`${baseURL}${id}`);const result = await response.json();return result;}async function init() {app.appendChild(loading);const pokemon = await getPokemon(1);loading.remove();}init();
Our loading indicator appears when the page first opens and disappears once the first Pokémon has loaded. Now we need to take the data we got from the Pokémon API and generate a DOM structure. We'll show the Pokémon name, number, image, and types.
function createPokemon(pokemon) {const pokemonElement = document.createElement("div");pokemonElement.id = "pokemonContainer";pokemonElement.classList.add("pokemon-container");const pokemonImage = document.createElement("img");// Get the dream world sprite, falling back on the official artwork and then the default artwork.// Set the src attribute directly on the element.pokemonImage.src =pokemon.sprites?.other?.dream_world?.front_default ||pokemon.sprites?.other?.["official-artwork"]?.front_default ||pokemon.sprites?.front_default;pokemonImage.classList.add("pokemon-image");pokemonElement.appendChild(pokemonImage);const pokemonInfo = document.createElement("div");pokemonElement.appendChild(pokemonInfo);const pokemonId = document.createElement("p");pokemonId.classList.add("pokemon-id");pokemonId.innerText = pokemon.id;pokemonInfo.appendChild(pokemonId);const pokemonName = document.createElement("p");// Capitalize the first characterpokemonName.innerText = pokemon.name[0].toUpperCase() + pokemon.name.slice(1);pokemonName.classList.add("pokemon-name");pokemonInfo.appendChild(pokemonName);const pokemonTypes = document.createElement("div");pokemonTypes.classList.add("pokemon-types");// Loop over all of the types and create a type badge.pokemon.types.forEach((type) => {const typeElement = document.createElement("div");typeElement.classList.add(type.type.name);typeElement.innerText = type.type.name;pokemonTypes.appendChild(typeElement);});pokemonInfo.appendChild(pokemonTypes);return pokemonElement;}
As an aside, functions like this make it easy to see why using declarative paradigms like React is so popular. Doing the same thing with React would look something like this:
const Pokemon = ({ pokemon }) => {return (<div className="pokemon-container"><imgsrc={pokemon.sprites?.other?.dream_world?.front_default ||pokemon.sprites?.other?.["official-artwork"]?.front_default ||pokemon.sprites.front_default}/><div><p className="pokemon-id">{pokemon.id}</p><p className="pokemon-name">{pokemon.name[0].toUpperCase() + pokemon.name.slice(1)}</p>{pokemon.types.map((type) => (<div key={type.type.name} className={type.type.name}>{type.type.name}</div>))}</div></div>);};
Much more concise while still creating exactly the same DOM structure.
At this point, we can bring it all together to render our single Pokémon.
async function init() {app.appendChild(loading);const pokemon = await getPokemon(1);loading.remove();app.appendChild(createPokemon(pokemon));}
And after a moment of loading, we should see Bulbasaur!
Events
Now that we've loaded our first Pokémon, we need to add buttons to load the other ones. Creating the buttons works exactly the same as regular elements; we'll just use button
as our tag name.
function createButtons() {const buttonContainer = document.createElement("div");buttonContainer.classList.add("button-container");const prevButton = document.createElement("button");prevButton.innerText = "Prev.";buttonContainer.appendChild(prevButton);const nextButton = document.createElement("button");nextButton.innerText = "Next";buttonContainer.appendChild(nextButton);return buttonContainer;}
Now that we have two buttons, how do we give them event handlers? We have two options.
Every event that we can trigger is available as a property on the element with the prefix 'on'. The event name itself is lowercase, which means our properties are "onclick", "onmousedown", etc. By assigning a function to these properties, every time the event is triggered, it will call the function.
nextButton.onclick = function handleNextPokemon() {// ...};
The second option involves adding an event listener using the addEventListener
method. You might have used this method to add events directly to the document; we're going to use it directly on the button. Instead of appending an 'on' to the front of the event name, we just use the event name as the first parameter; the second parameter is the function that is called when the event is triggered.
nextButton.addEventListener("click", () => {// ...});
I personally prefer using addEventListener
. It makes it easy to add multiple event listeners to the same element, and has extra options, such as making the event listener stop listening after the first time it is called.
Before we can go to the next or previous Pokémon, we need to know what the current Pokémon's ID is. You might be thinking that we could just grab it from the pokemonId
element, and you'd be right. However, you should using DOM elements as state storage. Since the DOM is globally accessible, and you can mutate any DOM element at any time, it's possible that the DOM element has changed in a way that you weren't anticipating.
This is another benefit of using a front-end framework. With React, you store your application state either in component state or using the useState
hook; your UI is always a function of that state, so the DOM elements that are rendered by React (or any other front-end framework) will be predictable. With Vanilla DOM APIs, you are responsible for making sure your state doesn't get messed up somewhere else in your program.
We'll create a top level variable to hold the ID of the current Pokémon as a number. We'll also change our getPokemon
function so it uses that state variable instead of having us pass a parameter to the function.
let currentPokemon = 1;async function getPokemon() {const response = await fetch(`${baseURL}${id}`);const result = await response.json();return result;}
Then we can write our event handlers, along with a helper to load and re-create our Pokémon DOM elements...
async function loadAndRenderPokemon() {// Clear the existing Pokemon.const pokemonElement = document.getElementById("pokemonContainer");pokemonElement.remove();// Show the loading elementapp.appendChild(loading);const pokemon = await getPokemon();loading.remove();app.appendChild(createPokemon(pokemon));}function goPrev() {if (currentPokemon <= 1) return;currentPokemon -= 1;loadAndRenderPokemon();}function goNext() {if (currentPokemon >= 893) return;currentPokemon += 1;loadAndRenderPokemon();}
...and add our event listeners to our buttons.
nextButton.addEventListener("click", goNext);prevButton.addEventListener("click", goPrev);
One thing that I'm doing is obliterating the existing Pokémon DOM elements when we load a new Pokémon. For our purposes, that works just fine. However, if you needed to be more performant and use less memory, it would be best to reuse the existing DOM elements and change out the innerText
and attributes. I'll leave figuring out how to do that as an exercise for the reader.
The last thing that we need to do is execute our createButtons
function inside of our createPokemon
method. Altogether, our JavaScript code should look something like this.
const baseURL = "https://pokeapi.co/api/v2/pokemon/";const app = document.createElement("div");app.id = "app";document.body.appendChild(app);const loading = document.createElement("p");loading.innerText = "Loading...";loading.classList.add("loading");let currentPokemon = 1;async function loadAndRenderPokemon() {// Clear the existing Pokemon.const pokemonElement = document.getElementById("pokemonContainer");pokemonElement.remove();// Show the loading elementapp.appendChild(loading);const pokemon = await getPokemon();loading.remove();app.appendChild(createPokemon(pokemon));}function goPrev() {if (currentPokemon <= 1) return;currentPokemon -= 1;loadAndRenderPokemon();}function goNext() {if (currentPokemon >= 893) return;currentPokemon += 1;loadAndRenderPokemon();}function createButtons() {const buttonContainer = document.createElement("div");buttonContainer.classList.add("button-container");const prevButton = document.createElement("button");prevButton.innerText = "Prev.";buttonContainer.appendChild(prevButton);const nextButton = document.createElement("button");nextButton.innerText = "Next";buttonContainer.appendChild(nextButton);nextButton.addEventListener("click", goNext);prevButton.addEventListener("click", goPrev);return buttonContainer;}async function getPokemon() {const response = await fetch(`${baseURL}${currentPokemon}`);const result = await response.json();return result;}function createPokemon(pokemon) {const pokemonElement = document.createElement("div");pokemonElement.id = "pokemonContainer";pokemonElement.classList.add("pokemon-container");const pokemonImage = document.createElement("img");// Get the dream world sprite, falling back on the official artwork and then the default artwork.// Set the src attribute directly on the element.pokemonImage.src =pokemon.sprites?.other?.dream_world?.front_default ||pokemon.sprites?.other?.["official-artwork"]?.front_default ||pokemon.sprites?.front_default;pokemonImage.classList.add("pokemon-image");pokemonElement.appendChild(pokemonImage);const pokemonInfo = document.createElement("div");pokemonElement.appendChild(pokemonInfo);const pokemonId = document.createElement("p");pokemonId.classList.add("pokemon-id");pokemonId.innerText = pokemon.id;pokemonInfo.appendChild(pokemonId);const pokemonName = document.createElement("p");// Capitalize the first characterpokemonName.innerText = pokemon.name[0].toUpperCase() + pokemon.name.slice(1);pokemonName.classList.add("pokemon-name");pokemonInfo.appendChild(pokemonName);const pokemonTypes = document.createElement("div");pokemonTypes.classList.add("pokemon-types");// Loop over all of the types and create a type badge.pokemon.types.forEach((type) => {const typeElement = document.createElement("div");typeElement.classList.add(type.type.name);typeElement.innerText = type.type.name;pokemonTypes.appendChild(typeElement);});pokemonInfo.appendChild(pokemonTypes);const buttons = createButtons();pokemonElement.appendChild(buttons);return pokemonElement;}async function init() {app.appendChild(loading);const pokemon = await getPokemon(1);loading.remove();app.appendChild(createPokemon(pokemon));}init();
You can check out the whole project here on CodeSandbox.
Custom Events
We didn't encounter this problem while we were making our little app, but you might find sometimes that you need to pass events from one place in your app to a completely different place. It would be nice if you could just listen to a custom event on the document
,and then fire that custom event from anywhere else in your app.
Guess what? Such a thing does exist, and it's called Custom Events. You can create custom events from anywhere in your app and dispatch them to any element in your page, including document
. Any event listeners listening for your custom event will be triggered and receive whatever data you sent them.
Here's an example where we dispatch a friendly greeting event.
const myElement = document.createElement("div");myElement.addEventListener("greeting", (event) => {console.log(`Greeting from:${event.detail.name}`);});// Elsewhereconst greetingEvent = new CustomEvent("greeting", {detail: {name: "Alex",},});myElement.dispatchEvent(greetingEvent);
When we use the CustomEvent
constructor, the first argument is the name of the event that the listener needs to subscribe to; the second argument is an object which holds any data we want to send over to the listener.
There we have it; a small app built with Vanilla DOM APIs. It might already be apparent, but using the Vanilla DOM API can quickly become cumbersome the larger the app becomes. You can also run into other pitfalls, such as naming collisions, multiple functions accessing the same mutable state, and memory leaks from event listeners not being cleaned up. Front-end frameworks take care of these problems for you so you can focus on making a great app.
Hopefully this little dive into DOM APIs has given you a better idea how these frameworks might work under the hood. Maybe you'll even use some of these principles as you work with front-end frameworks.