Browser Routing & History API
Single Page Applications: how to change the content
The idea of a SPA, vs a multi-page app, is that we’re only using one HTML page that will render all of our content. Otherwise, we would need multiple HTML pages for each part of our site, “About”, “Projects”, etc. This was the way people would make pages back in the day for simple static sites. Now that modern sites are so interactive, a SPA allows us to use only one HTML page and dynamically render content based on what a user has engaged with.
How do we do this from a DOM perspective?
There are two main techniques to change things from the DOM’s perspective:
Remove previous page or content and inject the new page or content into the DOM
<nav> <a href=''>Section 1</a> <a href=''>Section 2</a> <a href=''>Section 3</a> </nav> ~~<section id='section1'> </section>~~ <section id='section2'> </section>
Hide previous page and show current page
<nav> <a href=''>Section 1</a> <a href=''>Section 2</a> <a href=''>Section 3</a> </nav> <section id='section1'> </section> <section id='section2' hidden> </section> <section id='section3' hidden> </section>
This isn’t ideal for large-scale apps, but can still be very performant.
Single Page Application and Router
We want to change the content of the pages based on what the user selects:
Home Page: menu
Details of one particular product
Order: cart details and order form
We won’t have multiple HTML files, we will use the DOM APIs and Web Components.
We will use the History API to push new entries to the navigations history
History API
We can push new “fake” navigation URLs and listen for changes
//pushing a new URL; the second argument is unused
history.pushState(optional_state, null, '/new-url');
//to listen for changes in URL within the same page navigation
window.addEventListener('popstate', event => {
let url = location.href;
});
popstate
won’t be fired if the user clicks on an external link or changes the URL manually.If you’re creating a SPA, configure your server properly and check the URL when the whole page loads for the first time.
SPA Router from Scratch
Now, I can go ahead and code a router from scratch. I’ll start with an object with two methods: init
and go
.
//services/Router.js
const Router = {
init: () => {
},
// go() will ask the router to go to a new URL, a use case for this
// might be when implementing a login page, wouldn't want to go
// back to log in form after successfully logging in.
go: (route, addToHistory=true) => {
}
};
export default Router;
Once we’ve exported our Router, we’ll want to import it into our app.js
and add it to our app object, adding it as a router
property.
//app.js
//..
import Router from './services/Router.js';
app.router = Router;
Now, if I go back to my app and click on any of the current links, we can see that there’s a refresh happening, which means that every time the icons get clicked, it makes a new request to the server to render out the current page. And we don’t want links that we want to manage to make calls to the server. So to end this, we need to trap the click on those links with the DOM API, to prevent those links from hitting the server.
For that, we need to use e.preventDefault()
by first grabbing all the link elements we’re working with by using document.querySelectorAll()
since this method returns us a static Node List, we can use all the modern array methods at our disposal to loop through each and add an event listener that, on click, will prevent the default behavior:
//services/Router.js
const Router = {
init: () => {
document.querySelectorAll('.navlink').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
});
});
},
//go: () => {}
}
Now that we’ve successfully prevented the default operation of the browser from going to the href
found in our nav element, we need to find a way to go there. This begins by first figuring out where our current location is.
One way to get the URL is to use use the .href
property from our anchor element called link
. However, there is also another way, using the .getAttribute('href')
DOM API method.
link
variable in the forEach
loop, which is perfectly acceptable, but we can also directly reference the event
object which also has direct access to the DOM APIs we’re working with.In our specific example, this doesn’t matter as either technique will work, but the difference in using the closure vs the direct event target, is that the event target is giving us the target or subject of the event aka the element that was clicked.
init: () => {
document.querySelectorAll('.navlink').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
//const url1 = event.target.href;
const url2 = event.target.getAttribute('href');
Router.go(url);
});
});
Router.go(location.pathname);
},
One last thing to do in my init()
function, is to check the initial URL bc maybe the user is starting from a specific route. We can do this by using the location.pathname
property (this property exists on Window
) and pass it to our Router.go()
function.
Now, we need to consider handling the case where we want to add to history. We can simply add to our history using the pushState()
method. Again, history
here is a property from our global Window
object.
//services/Router.js
const Router = {
//init: () => {}
go: (route, addToHistory=true) => {
if(addToHistory) {
history.pushState({route});
}
})
}
Changing DOM Element Content
As we saw before, us manually adding a route using pushState()
will simply “fake” that route in our browser. From there we can make a switch case based on what the route is and do something with the DOM (either hide/show or remove/inject the page). Where should we do this? Well, we can use a <main>
element and inject/remove elements there:
//services/Router.js
const Router = {
//init: () => {}
go: (route, addToHistory=true) => {
if(addToHistory) {
history.pushState({route});
}
let pageElement = null;
switch(route){
case '/':
pageElement = document.createElement('h1');
pageElement.textContent = 'Menu';
break;
case '/order':
pageElement = document.createElement('h1');
pageElement.textContent = 'Your Order';
break;
}
document.querySelector('main').appendChild(pageElement)
})
}
Now that we can add HTML elements to our page, one quick problem we can see is that there is no current way to remove previous or outdated elements on the DOM. For this example we see multiple <h1>
tags being appended to the DOM on every click to the respective icons and routes. This is very common in VanillaJS since a lot of frameworks take care of this or abstract it away like React.
Two ways to do it:
Quick and dirty: Change
innerHTML
from the element to an empty string.document.querySelector('main').innerHTML = '';
Using the
children
property and calling theremove()
method on itdocument.querySelector('main').children[0].remove();
children
and childNodes
: children
gives us an HTML collection of DOM elements, whereas childNodes
returns a NodeList
that gives comments as objects and text as objects aka \n
and empty spaces.Now, what happens if we go to an invalid route? If we don’t have a page to render, it’s because the router couldn’t find a route. For this, we could do a client-side 404 page and add logic to handle this case:
//services/Router.js
//go: () => {
//...
let pageElement = null;
switch(route){
case '/':
pageElement = document.createElement('h1');
pageElement.textContent = 'Menu';
break;
case '/order':
pageElement = document.createElement('h1');
pageElement.textContent = 'Your Order';
break;
}
if(pageElement){
const cache = document.querySelector('main');
cache.innerHTML = '';
cache.appendChild(pageElement);
window.scrollX = 0;
window.scrollY = 0;
}
}
window.scrollY
and window.scrollX
because when you have a large page with a lot of content and are scrolling the page, when you change the route, you’ll still be in your current scroll position, and we want to start from the top of the new page.Dynamic Routing
Now that we’ve got our router working, another consideration we have to take in is how to handle a dynamic route, ie: a route like product details that has a ID associated with the specific product we want more details on.
Turns out we can add a default
case to our switch statement and check if the route that doesn’t fit with our other pre-defined routes starts with something in particular that we’re looking to handle such as a product details page. From there we can make use of the dataset
property of our HTML element. Which is a read-only property that provides read/write access for custom data attributes— similar to data-*
that I’ve seen in things like React or Jest and doesn’t get parsed by the browser.
//services/Router.js
switch(route){
//...
default:
if(route.startsWith('/product-')) {
pageElement = document.createElement('h1');
pageElement.textContent = 'Details';
const paramId = route.substring(route.lastIndexOf('-') + 1);
pageElement.dataset.id = paramId;
}
}
Now that we’ve got that, if you go back and navigate forwards and backward in our app, our HTML content doesn’t change, even though our browser URL does. This happens because we’re not listening to the popstate
event just yet. So let’s go do that right now:
const Router = {
init: () => {
//...
// Event Handler for URL changes
window.addEventListener('popstate', event => {
//setting addToHistory as false here since the user is going back
Router.go(event.state.route, false);
})
}
}
Web Components
Overview & Custom Elements
Web Component:
A modular, reusable building block for web development that encapsulates a set of related functionality and user interface elements. AKA in short our own custom HTML element.
Compatible with every browser
It’s actually a set of standards:
Custom Elements
HTML Templates
Shadow DOM
Declarative Shadow DOM (new)
Similar to the idea of components on most of the libraries for JS
We have freedom of choice on how to define and use them
Custom Element:
A way to define new, reusable HTML elements with custom behavior and functionality using JS. We can define our own HTML tags using the Custom Elements API:
class MyElement extends HTMLElement {
constructor(){
super();
}
}
customElements.define('my-element', MyElement);
Would output the following:
<body>
<my-element></my-element>
</body>
<script>
document.createElement('my-element');
</script>
Custom Elements with Attributes
We can define our own custom attributes using the data-*
spec:
class MyElement extends HTMLElement {
constructor(){
super();
this.dataset.language
}
}
customElements.define('my-element', MyElement);
Our HTML would be:
<body>
<my-element data-language='en'></my-element>
</body>
Custom Elements Lifecycle
We can override some methods of the super
class
class MyElement extends HTMLElement {
constructor(){ //set up initial state, event listeners, etc.
super();
}
connectedCallback() {} //The element is added to the document
disconnectedCallback() {} //The element is removed from the document
adoptedCallback() {} //The element has been moved to a new document
attributeChangedCallback(name, oldValue, new Value() {})
}
Custom Elements with Slots
The slot is the content that we can define as the component’s children. With templates, we can have more than one slot. Like the children
prop in React components.
<body>
<my-element data-language='en'>
<div>
<h1>Slot of My Element</h1>
</div>
</my-element>
</body>
Custom Elements with Customized Builtins
We can extend HTML elements with our own implementation. Not available in Safari in 2023.
<div is='my-element'>
</div>
HTML Templates
Template Element:
Fragments of markup that can be cloned and inserted into the document at runtime, with reusable HTML content that can be rendered and modified dynamically.
It allows us to define HTML content that is not going to be parsed by the browser and it’s going to be available for usage only if we clone it.
<template id='template1'>
<header>
<h1>This is a template</h1>
<p>This content is not rendered initially</p>
</header>
</template>
Typically to clone it we need to access it, which is why we put an id
attribute on it as well.
Template Element Cloning:
We clone the template and append it as a child; typically in the connectedCallback
method of the Custom Element.
connectedCallback() {
const template = document.getElementById('template1');
const content = template.content.cloneNode(true);
this.appendChild(content);
}
<template id='template1'>
<style>
/*This declaration also changes h1s outside of the custom element*/
h1 {color: red}
</style>
<header>
<h1>This is a template</h1>
<p>This content is not rendered initially</p>
</header>
</template>
Shadow DOM
A private, isolated DOM tree within a web component that is separate from the main document’s DOM tree.
Allows more control over styling and encapsulation of functionality of a Custom Element
By default, CSS declared in the main DOM won’t be applied to the Shadow DOM
CSS declared in the Shadow DOM applies only there, not the outside
There are new pseudo-classes and pseudo-element to allow communication between DOMs in stylesheets
It can be opened or closed defining visibility from the outer DOM
We have to create a Shadow DOM manually in our Custom Element, typically in the constructor and we save it.
class MyElement extends HTMLElement {
constructor() {
super();
this.root = this.attachShadow({mode: 'open'});
}
connectedCallback() {
this.root.appendChild(...);
}
}
The Shadow DOM is completely separate from the browser DOM.
Where to define HTML for a Custom Element
There are several alternatives:
Use DOM APIs
Use a
<template>
in the main HTMLUse an external HTML file loaded with fetch (it can be prefetched) (preload link element)
Using
innerHTML
Use
DOMParser
Declarative Shadow DOM
A way to define Shadow DOM directly in HTML markup using a new set of attributes and tags. This is dependent on browser compatibility.
Where to define CSS for a Custom Element
There are several alternatives:
Use CSSOM APIs (A DOM for CSS) API in JS to create style sheets
Add a
<script>
to a<template>
Add a
<link>
in the<template>
Use an external CSS file loaded with fetch (can be prefetched) and injected in the Shadow DOM as a
<style>
Web Components are made up of 3 independent APIs:
Custom Elements
HTML Templates (and cloning them to use them)
Shadow DOM
These three APIs end up being a sort of Design Pattern that is Web Components.
Creating Web Components
Now let’s create our 3 web components representing our Menu Page, Details Page, and Order Page.
//components/MenuPage.js
class MenuPage extends HTMLElement {
constructor() {
super();
this.root = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.root.appendChild();
}
}
customElements.define('menu-page', MenuPage);
Now, as an experiment, say I add my new custom element <menu-page>
to my HTML, it shows up in the DOM, but it’s not rendered. This is because when the browser finds an unknown element, it just ignores it bc there’s no link from the HTML file to my MenuPage.js
file.
Once I export my class, import
it in my app.js
file, it will execute that code, which will register my custom element with the DOM through the customElements.define()
function.
However, we’re not going to use our custom element by adding it manually to our HTML file, we’re going to do it from the Router. If we go back to our switch case in our Router.js
file, instead of creating random h1
elements, we can insert our custom elements instead. This works similarly to react-router
, which I've used in the past in React apps.
switch (route) {
case '/':
pageElement = document.createElement('menu-page');
pageElement.textContent = 'Menu';
break;
case '/order':
pageElement = document.createElement('order-page');
pageElement.textContent = 'Your Order';
break;
}
Loading Templates
Now, let’s get into templates. One thing that we have to remember is that templates are unusable until they’re cloned. Meaning that we can’t just inject/append them into the DOM— we need to clone and create a real instance of the template first.
To load templates, by convention, we call it the content and clone by using the following syntax template.content.cloneNode()
//components/MenuPage.js
export class MenuPage extends HTMLElement {
constructor(){
super();
const template = document.getElementById('menu-page-template');
const content = template.content.cloneNode(true);
this.appendChild(content);
}
}
However, once we load this in the browser, we get the following error:
Apparently, this is because when we are constructing an element, we cannot have children. Two ways around this: using the shadow DOM or using the connectedCallback()
method. For this example, let's use the connectedCallback
, so we'll move our template cloning logic to the body of the connectCallback
method.
//when the component is attached to the DOM
connectedCallback() {
const template = document.getElementById('menu-page-template');
const content = template.content.cloneNode(true);
this.appendChild(content);
}
The connectedCallback()
is sort of a component lifecycle like React class components (not sure but this is what it reminds me of), when the component gets attached to the DOM.
Applying a Shadow DOM
Typically you create Shadow DOM in the constructor and in fact, could move templating cloning in the constructor and append it to the newly constructed Shadow DOM.
export class MenuPage extends HTMLElement {
constructor() {
super();
this.root = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
//...
this.root.appendChild(content);
}
}
Styling Web Components
The best way to load CSS in JS might be to define a function within the constructor.
export class MenuPage extends HTMLElement {
constructor() {
super();
this.root = this.attachShadow({ mode: 'open' });
const styles = document.createElement('style');
this.root.appendChild(styles);
async function loadCSS() {
//request our CSS file
const request = await fetch('/components/MenuPage.css');
//Read CSS file using text() not json()
const css = await request.text();
styles.textContent = css;
}
loadCSS();
}
//when the component is attached to the DOM
connectedCallback() {
const template = document.getElementById('menu-page-template');
const content = template.content.cloneNode(true);
this.root.appendChild(content);
}
}
customElements.define('menu-page', MenuPage);
Could also load it through a <link>
in our HTML file as well, it would look like this:
<link rel='preload' href='components/DetailsPage.css' as='stylesheet'></link>
That's it for today! I covered all the basics of routing and dynamic routing, as well as Web Components, and what goes into them. Additionally, I created my own custom Web Component and utilized my templates to clone and then inject my Web Components based on the router.