SPAC: Web APIs for Page Transitions & Persistence
SPAC is a custom JavaScript framework for client-side, single-page web applications. It stands for "Stateful Pages, Actions and Components". Its design goal is to provide robust and simple entities that help you to structure apps. Pages and components provide the HTML, JavaScript functions and UI interactions. Actions govern external API calls. You define these entities in plain JavaScript, load up the central controller, and your app is ready to be served. Read the development journey of SPAC in my series: https://admantium.com/category/spac-framework/.
Web APIs offer powerful features for your web apps. In this article, I explore the Storage API, History API and the events that are triggered for loading resources and URL. These APIs will be used for two features of the SPAC framework: Persisting the state of the application, so you can resume the app in the same state that you left it when you closed your browser. And routing, the ability to resolve internal routes without triggering a browser reload.
Persisting State
Web Storage API
The Web Storage API offers essential methods to store and retrieve string objects in the local browser storage. The storage comes in two flavors. Session storage remains in the browser until the session is terminated by the application, or the browser tab is closed. Local storage is persisted with no expiration date, session termination or closing the tab/browser does not delete the data. It needs to be explicitly cleaned by the app, or by the user who deletes the browser cache.
To interact with the storage, the API offers the following methods:
setItem(key, value)
- Sets a new item with the givenkey
andvalue
getItem(key)
- Retrieves the storedvalue
for the givenkey
removeItem(key)
- Removes thevalue
for the givenkey
key(int)
- Retrieves the storedvalue
for the given integer valuesclear()
- Removes allkeys
andvalues
...
SPAC Implementation
SPAC defines a StorageProviderInterface
entity that can be used to persist the state of an application.
class StorageProviderInterface extends Interface(
'init',
'load',
'persist',
'clear'
) {}
This interface defines these methods:
init()
: Initialize the storage structure, e.g. setup database tables or define the JSON structureload()
: Load the persisted data from the configured storepersist()
: Persists the data to the configured storeclear()
: Deletes all data
Any concrete instance of this interface needs to implement all methods, otherwise the class instantiation fails.
SPAC ships with a lightweight BrowserStorage
class that is implemented as follows:
class BrowserStorage extends StorageProviderInterface {
constructor (appName) {
super()
this.appName = appName
}
init () {}
persist (appState) {
document.localStorage.setItem(
this.appName,
JSON.stringify({ ...this.load(), ...appState })
)
}
load () {
return JSON.parse(document.localStorage.getItem(this.appName))
}
clear () {
document.localStorage.clear()
}
}
As you see, the BrowserStorage
uses the the setItem
and getItem
methods from the persistence API. The application state is serialized into a JSON string (because we can only store strings), and when loaded, it gets deserialized. The BrowserStorage
does not need an init
method, so it is not implemented. Calling clear
deletes everything in the store.
Router
Routing is an important concept in web applications, and especially in single-page-apps. Traditionally, every time you make an HTTP request to show a page, let's say /index
and /search-api-elements
, the web server will look for similar named files and your browser will update the currently displayed page. Single-page-apps just work differently: Instead of re-rendering the complete page, only (parts of) its DOM are replaced. The router, then, has the responsibility to intercept page reloads, but force an update of the DOM that represents the resources at the currently displayed route.
However, changing the application path triggers internal browser events - and for this, we have some options regarding the Web APIS.
Web Apis
The History API allows the manipulation of the browsers session history. For a single page app, this is especially handy for two reasons. First, we can use the history to persists stage snapshots of the app, and thus allow the user to use the "back/forth" browser buttons to go back in time. Second, when pushing new entries to this history, the browser automatically does not trigger a page reload, which automatically solves one of the above-mentioned requirements. It offers these methods:
pushState(state, title [, url])
: The new URL can be any domain within the current domain, and it even does not need to change at all.replaceState(stateObj, title, [url])
: Replaces the current state, and changes the browser location to the specified URL (weird: There is no check that this URL actually exists)back()
: Go back one step in the historyforward()
: Go forward one step in the historygo(int)
: Go to the specified index in the history
When the browser accesses a page, it stores the current information in the location
object. This object has the properties path
, which is the full path, and it can have a hash
, which points to the fragment of the page. Ideally, the router can resolve both types of routes.
Router Requirements
Armed with this knowledge, we can solidify the requirements for routing in a single-page-app as follows:
- Define an inventory of all internal routes
- Resolve those routes and update the displayed DOM to match the current route
- Intercept all location changes that point to defined routes (preventing a page reload)
- Not intercept calls to internal resources (css, non-framework related js, images)
- Not intercept location changes to external resources (but probably warn the user)
This is a lot to cover. But the expressive nature of JavaScript code can boil this down to a few lines of code.
SPAC Implementation
The inventory of routes is created during the self-initialization phase. After this, we will obtain a map object that is structured as follows:
pages = {
'Index' => {
route: '/index',
obj: [Function: IndexPage]
},
'SearchApiElements' => {
route: '/search-api-elements',
obj: [Function: SearchApiElementsPage]
}
}
The main router function is called goTo
and receives an url
parameter.
goTo (url) {
const isLocalURL = url.match('^/') ? true : false
const isInternalURL = url.match(new RegExp(this.baseHostname)) ? true : false
const isRessourceFile = url.match(/[.]\w+$/) ? true : false
if (!isRessourceFile && (isLocalURL || isInternalURL)) {
isInternalURL ? (url = `/` + url.split('/').pop()) : ''
let loadablePage = false
this.pages.forEach((obj, key) => {
obj.route == url ? (loadablePage = key) : ''
})
if (loadablePage) {
window.history.pushState({ emptyState: 'empty' }, `Change to ${url}`, url)
this.display(loadablePage)
} else {
console.error(`Route ${url} not defined, staying on current page`)
}
} else if (isRessourceFile) {
window.history.pushState({ application: 'exit' }, 'Exiting App', this.baseHostname)
window.location.replace(url)
} else {
window.history.pushState({ application: 'exit' }, 'Exiting App', this.baseHostname)
window.location.replace(url)
}
}
Here, the following things happen:
- Line 2 & 3: Determine if the URL is either a local path or if it includes the hostname
- If either condition is true, then ...
- Determine the route
- Check that for this route, there is an entry in the
pages
map - If yes, make an entry on the history and mount the page
- If no, log an error but stay on the current page
- if the URL points to a file, load it
- If none of the above conditions is true, then
- make a final entry to the History API
- Assign a new
window.location
, forcing a page reload
The goTo
function can be called explicitly from within a page. However, local routes can also be embedded in plain HTML <a>
elements. And therefore, after any page has completely loaded - shown by the load event, we attach a custom event listener to <a>
tags that intercepts the call and redirects them.
class Controller() {
/...
_interceptLinkResolutions () {
window.addEventListener('load', () => {
window.document.querySelectorAll('a').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault()
goTo(e.target.href)
})
})
})
}
}
The final piece is to intercept any other location change, either forced by the user when he enters a new URL in the browser address bar, or by a third party script. For this, we use the beforeunload event, which is a cancellable event triggered before the current page is replaced or reloaded. A confirmation dialog is shown to the user, and he needs to confirm to exit the application, which terminates the session and removes all non-persisted state.
class Controller() {
//...
interceptPageRefresh() {
window.addEventListener('beforeunload', (e) => {
e.preventDefault();
e.returnValue = ''
if (window.confirm('The page will be reloaded. All session date is delete. Continue?')) {
return;
}
})
})
}
And with this methods, the basic routing capabilities of SPAC are implemented.
Conclusion
With the SPAC framework, you build stateful single-page-applications with pages, actions and components. This article showed how the session data can be persisted with the HTML Storage API, an intuitive way to store key-values pairs in the browser. Specifically, SPAC provides a module that persists data in the local storage, data that is not cleared after the browser session is terminated. The second API is the History API, an internal data structure that records changes URL or state changes for a session in the same hostname. This API enables the user to jump back and forth with its browser. For single-page-apps, this is especially important because URL changes should not lead to a page refresh. Using the API, and event listeners at the load
and beforeunload
events, prevents unintended page loads that would terminate the current session.