Modern web design aims for seamless, swift user experiences reminiscent of native applications. While Single Page Applications (SPAs) cater to this, what if we could mirror this behavior in the simpler and scalable Multi-Page Applications (MPAs)? This article delves into a method to achieve such smooth navigation in MPAs.
You can see a working example of this technique in roquec.com. The frame around the content (menu, tabs…) maintains its state while the content changes with navigation.
Context
This article is aimed at intermediate to advanced front-end developers with a base knowledge of javascript and web development technologies and architectures.
What is SPA?
A Single Page Application (SPA) dynamically rewrites its current content instead of loading new pages, providing a fluid and faster experience. Read more about SPAs here.
Why Choose MPA?
Despite the appeal of SPAs, many developers still opt for MPAs because of their simpler architecture, scalability, and easier SEO management. Additionally, MPAs don’t require heavy frameworks or extensive client-side scripting.
Challenge
Let’s imagine we want to create a website with a side menu that can be open or closed. This is controlled by a class that gets added to the menu element. We want the state of that panel to stay consistent through page navigations. Let’s review some options:
Naive implementation
A straightforward approach might involve storing the state in localStorage. Then, when the next page loads (at the DOM ready event), we retrieve the state and adjust the menu. However, this can cause flickering since the browser initially renders the default state before we apply our changes.
Applying State Before DOM Loads
While we might think about applying the state changes as soon as possible, this would mean acting before the full DOM has loaded. As a result, our desired element (like the menu) might not yet be accessible, leaving us with no way to add our class to it.
Using Styles Over DOM Modifications
We could inject style rules in the head
or apply them to the html
before the DOM loads allowing us to prepare styles depending on the state of the elements beforehand. This approach does indeed work but has some serious limitations.
First of all now we cannot just add our class to the element and rely on our css, we need custom style rules to be added via javascript to override the normal css (probably need !important
). This means duplication of the css and bad maintainability. Secondly this solution is limited to what CSS can do, for example we can’t make changes to the inner text of an element. All in all this approach is hacky and not reliable enough for us.
Solution
So if we can’t apply the state changes before the DOM loads or after the DOM is ready, what can we do?
The solution I propose is to leverage the power of MutationObserver
to apply our state as soon as the elements we are interested in are added to the DOM. This approach allows for instantaneous updates without waiting for the entire DOM to be ready, eliminating flicker and creating a smooth transition.
The process would look like this:
1. Initialization
As soon as your JavaScript runs in the head (even before the full DOM loads), initialize the MutationObserver
.
const config = {attributes: false, childList: true, subtree: true};
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, config);
- The
config
specifies what ourMutationObserver
is checking for, in our case we are interested in changes in the whole subtree. - We create the
MutationObserver
with a reference to ourcallback
function where we’ll handle the changes to the elements. - Finally, we start observing for changes by calling the
observe
method.
2. Observation
Look for the elements we are interested in within the callback
function of our MutationObserver
.
function callback(mutationList, observer) {
for (const mutation of mutationList) {
for (const addedNode of mutation.addedNodes) {
if(addedNode.id === "menu-panel"){
applyMenuPanelState(addedNode);
}
}
}
};
- This is our callback function, the
MutationObserver
will call it for every single element that is added to the DOM. - In our case we are interested in our menu panel element, we check for it with
addedNode.id === "menu-panel"
. - We then call our
applyMenuPanelState
function to set the state of the menu panel element.
3. Apply state
Read the state of the element from storage and make any necessary modifications to it.
function applyMenuPanelState(element) {
const state = localStorage.getItem("menu-panel-state");
if(state === "closed"){
element.classList.add("closed");
}
};
- This function sets the state of our menu panel element depending on the stored state.
- First we read the state from
localStorage
. - Then we apply the state, in this case if the panel is supposed to be closed we add the
closed
class to it.
4. Cleanup
After the DOM is ready, you can opt to stop the observation, ensuring that the observer doesn’t unnecessarily consume resources.
observer.disconnect();
- The call to
disconnect
makes the observer stop the notifications on DOM changes.
Next steps
A more involved implementation of this solution can be found here. In this case any js module can register rules to set the initial state of elements and the management of the MutationObserver
is encapsulated in a State
class. For example:
stateManager.setStateById(
Resizer.RESIZER_TARGET_ID,
(element) => element.style.width = width
)
- The function
setStateById
encapsulates the same solution we implemented previously but in a more reusable and maintainable way. Resizer.RESIZER_TARGET_ID
is the ID of the element we are interested in.(element) => element.style.width = width
is the callback where we apply the state to the element.
Concerns
While this solution seems promising there might a risk of performance issues at larger scales. If you are thinking of implementing this on a bigger application be sure to test the performance impact of running MutationObserver
during DOM load.
Also make sure MutationObserver
is supported in all your target browsers: compatibility.
Conclusion
Utilizing the MutationObserver
offers a straightforward solution to a nuanced problem. It bridges the gap between the fluidity of SPAs and the architectural simplicity of MPAs. Experiment with the MutationObserver
to enhance your MPA’s user experience. If you’ve tried this at a larger scale or have improvements, I’d love to hear about your findings.
Have fun with your smooth stateful MPAs!