I Tried Creating An SPA With JavaScript and HTML 🤯
Saje
Posted on August 7, 2023
Due to the popularity of several modern frontend frameworks, many frontend developers are gradually losing touch of what it's like to build an entire application without using any framework or library.
Based on my previous article, I decided to try out what it would be like to create a Single Page Application (SPA) using only JavaScript and custom elements. So I ended up creating a "mini" ecommerce store.
Even though I couldn't complete it, it provided a few insights which I found interesting.
Before we continue, you can check out the web page here or check out the repo here.
A single-page application (SPA) is a web application or website that interacts with the user by dynamically rewriting the current web page with new data from the web server, instead of the default method of a web browser loading entire new pages.
One popular concept in SPAs is Component Driven Development, which is basically an approach of separating your code into smaller reusable components that deliver a single/simple functionality.
After publishing an article on custom elements, which provided a way to create reusable components using vanilla JavaScript; out of curiosity, I've been thinking and enquiring how far we can push custom elements, or more specifically, how much can we do in JavaScript without our favorite frameworks/libraries, perhaps using just custom elements? When I asked this on social media, some of the reactions were quite funny but they sparked some interest in me. I decided to sacrifice some time to choose stress (as one person described it), and find out how much stress it could cause. Turns out, it is quite a lot of stress - but not as much as I had imagined.
There are three major concepts that distinguish an SPA:
- Structure: by structure, I mean, the folder/code structure, the layout, components, etc.
- Routing: this, of course is very crucial since routing is handled in the frontend
- State management: even though this is not unique to SPAs alone, state management is very crucial in an SPA
Based on these three concepts, I've split this article into three sections, to discuss each of these topics based on how I approached them in my little adventure.
Folder Structure
This aspect was perhaps the simplest aspect of the project - thanks to custom elements. As I explained in the article on custom elements, you can easily create reusable html components with JavaScript. So, as you might expect, I made a deliberate effort to make the folder structure look similar to what you'd have in a React application. Here's a screenshot of the expanded folder structure:
Yes, quite a lot of files, I know - some of them aren't even being used at the moment - but this was a somewhat sarcastic mimicry of an SPA folder structure 😅
And if you're curious how the snippet of the components looks, here's the navbar
:
Routing
Basic routing in an SPA is quite straightforward and relatively easy to implement. The way React Router works under the hood, for example, is by generating a routes
object which contains all the defined routes, and then throw a 404
error when a user tries to access a route that is not among the predefined routes.
To mimic this, I just created a routes
object that looks like this:
// routes.js
// all available routes
const routes = {
'?about': () => {
return about()
},
'?home': function () {
return product()
},
'': async () => {
return await home('')
},
404: () => {
error404()
},
}
export default routes
It looks a bit messy I know, but there's a reason for that...
In the snippet above, I created an object with the route
as the key, and a function which returns another function as the value. The reason for using Higher Order Functions is because I wanted to be able to pass arguments to the template function (to create 'dynamic' routes), and this seemed like the easiest approach.
Notice that I used search params
(?
), instead of regular routing (/
) - more on this soon.
// app.js
// import the routes object
import routes from './api/routes.js'
// Main container
const main = document.querySelector('.root')
// switch route without reloading the page (default behavior of links)
function handleRoute(event) {
event = event || window.event
event.preventDefault()
window.history.pushState({}, '', event.target.href)
handleLocation()
}
// check if the current path (url) is part of the preconfigured routes, else display the 404 page
async function handleLocation() {
const path = window.location.search
const templateFunc = routes[path] || routes[404]
const html = await templateFunc()
main.innerHTML = html
}
// make handleRoute() a method on the window object
window.handleRoute = handleRoute
// when user clicks back/forward, trigger the location function
window.onpopstate = handleLocation
// call the location function on first page load
handleLocation()
// export the function so it can be used within custom elements
export { handleRoute }
The comments in the snippets above are self-explanatory; first we import the routes
object, then create a handleRoute
function which we'll add to our links for routing across pages. The function takes an event
argument (which is triggered when a user clicks a link), prevents the default behavior of the event (stop the page from reloading), and calls history.pushstate
to 'push' the user to the URL specified in the link, after which we call the function: handleLocation
.
handlelocation
is for getting the current URL the user is on (in this case the search params - which was what we used in the routes object) using window.location.search
, check if that route is in the routes
object. If it is, we return the contents of the route, else we return the 404
route, and then finally call the function returned by the route
and append its content to the main page wrapper.
After configuring our routes, we can now create our "soft links" to link different pages without reloading the browser:
<button class="nav-btn"><a href="/" onclick="handleRoute()">Home</a></button>
Different ways of doing the same thing
There are different ways to implement routing in an SPA. Let's look at three of these:
-
Regular Routes (/): This is the standard approach often used in routing libraries, like React Router, etc. This method allows you to create routes that appear as separate pages, using forward slashes (/). To use this method, we'd need to modify the
handleLocation
function to usewindow.location.pathname
instead ofwindow.location.search
,
// app.js
// check if the current path (url) is part of the preconfigured routes, else display the 404 page
async function handleLocation() {
const path = window.location.pathname
const templateFunc = routes[path] || routes[404]
const html = await templateFunc()
main.innerHTML = html
}
Then modify the routes object:
// routes.js
// all available routes
const routes = {
'/about': () => {
return about()
},
'/home': function () {
return product()
},
'/': async () => {
return await home('')
},
404: () => {
error404()
},
}
export default routes
However, when using this method, you also need to configure your server to return a single HTML page for all routes entered by the user. Without doing this (since the HTML is always parsed before the JavaScript), the browser throws an error when it tries to access an HTML file for a route that doesn't match an existing HTML file. Of course if you're interested, you can easily use Express or any similar tool to implement this.
- Query Params: This is the method I used above so I think you already get the idea. The main advantage of this (which made me choose it) of course, is that the browser already considers a URL with a query parameter as part of the base URL, so everything works "as a single page" without requiring any server-side configuration.
- Hash URL: This - as you'd expect, is similar to using query parameters. The only difference is that it uses the hashchange event instate of the popstate event used with query parameters. You can check out this video for more on this:
What about dynamic routes?
The first approach that crossed my mind for creating dynamic routes was to create a function that accepts a path
and template
(in this case, a function that returns a template), and appends it to the routes
object.
// programmatically create a new route and append it to the routes object
async function createRoute(path, template) {
routes[path] = await template
}
Unfortunately this didn't work, of which I can't yet fully visualize why (perhaps you could help).
Because of this, I had to proceed with my initial idea of passing props
to the template function for the route. The complete route
object ended up looking like this:
// route.js
// all available routes
const routes = {
'?about': () => {
return about()
},
'?home': function () {
return product()
},
'': async () => {
return await home('')
},
'?category=jewelery': async () => {
return await home('jewelery')
},
'?category=electronics': async () => {
return await home('electronics')
},
'?category=men%27s%20clothing': async () => {
return await home("men's clothing")
},
'?category=women%27s%20clothing': async () => {
return await home("women's clothing")
},
404: () => {
error404()
},
}
Note: I used encodeURI to format the
prop
before appending it to endpoint for fetching the products.
State Management
State management is arguably the most crucial, mind-boggling and perhaps most difficult aspect of frontend development today - especially in an SPA; this difficulty is escalated when you try to implement the same functionality without using any specialized tool (a state management library/package).
While it's relatively easy to fire events with immediate side-effects within a component, sharing and maintaining state in real-time across components is almost a nightmare if you're trying to re-invent the wheel. For example, my initial plan for the products category was to create a single route and then fetch and display the content based on selected category. To achieve this, I added an event listener in the connectedCallback
method of the custom element to fetch and display the products based on a selected category, and then use recursion to re-render the component in order to reflect the state change:
// home.js
// event listener to render a selected product category
connectedCallback() {
this.shadowRoot.querySelectorAll('.category')
.forEach((navLink) => navLink.addEventListener('click', async (e) => {
let selectedCategory = e.target.textContent
// update the selected product category
category = await fetchProductCategory(selectedCategory)
// call the home function to cause a re-render
home()
}))
}
However, a weird behavior which I noticed was that, even the component was re-rendered, it still maintained the same stale data - basically, showing the default category instead of the new one. This led to the messy nature of the routes
object as stated earlier.
Admittedly, there might be other better alternatives or methods to effectively manage state in a Single Page Application using plain old JavaScript, but I'm yet to come across any. In my research, I came across several speculative implementation of the useState
hook like this one, but none of them was able to solve the challenge; and so with a little hesitation, I gave up.
Conclusion
Yes, it is technically possible to create single-page applications using web components. However, one of the foundational issues with this method —which inherently applies to all SPAs— is the amount of JavaScript shipped to the browser. The total unminified size of JavaScript in the above project is about 2MB, which is quite A LOT of JavaScript for such a small app; of course, using a module bundler would significantly reduce this size, however, this doesn't override the performance impact experienced by the user on initial load.
The purpose of this little challenge —and even this article— is not necessarily to encourage developers to stop using frameworks or libraries, the main reason why these technologies were invented in the first place was to solve different challenges in the software development process; so, most of them are definitely useful and important. However, my perception is that developers should be curious enough to truly understand how these tools work and the tradeoffs involved when using them.
I hope this was a fun read for you, and don't forget to check out the main article before this on custom elements.
Posted on August 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.