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 element
app.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 selector
const 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 character
pokemonName.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">
<img
src={
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 element
app.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 element
app.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 character
pokemonName.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}`);
});
// Elsewhere
const 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.