Links in a single page app
Updated: Since the original posting, this has been updated with some corrections thanks to Dan Pupius.
Some websites benefit from the single page application approach, which lets users navigate within the site without each navigation being a full page load. Gmail is one obvious example. But historically, the techniques used to implement single page applications have also made it easy for implementers to make a frustrating site: one where URLs don't make sense, where the back button doesn't work, or where links don't behave like real HTML links.
Here's an approach to implementing the single page pattern on today's web that makes it easy to get it right. It's based on three principles.
-
Every view must have a real URL. On a normal web page, at any time I can grab what's in my browser's URL bar and either share it or bookmark it.
-
Every link must be a real link. On a normal web page, I can view a link's destination by hovering it with my mouse; right-clicking produces a menu of link-specific options. So many otherwise-good pages try to implement their own link-like behavior by catching clicks on specific DOM nodes but then screw up corner case behaviors like middle-click.
-
It's ok to be less awesome on old browsers. (Of course, whether this is actually true depends on your site's goals.) One tactic for old browsers is just to fail gracefully: if the site still works but is just slower, that's ok. For example, Gmail used to implement each of its buttons with a soup of DOM nodes to get the gradients and rounded corners to show on IE6; on today's web, maybe it's ok to just use some newer CSS for those effects and allow the buttons to be square and flat on IE6.
Now let's put these constraints together. Because of principle 1, your
app must be capable of rendering any given view from scratch, because
the URL load might be a freshly started browser that just loaded a
bookmark. Because of principle 2, your links must be implemented
as plain old <a>
tags with an href
that points at the URL of the
resulting view.
In fact, you could stop there and you'd have a working site: each link click would just load the target as a new URL. That satisfies principle 3.
And here, finally, you can sprinkle in the single-page magic. What
you want to do is short-circuit some of those link clicks such that
the page updates. You can do this in one central place by just
intercepting clicks on <a>
tags right before the browser is about to
use them to navigate, and then use the history.pushState
API
to make the URL update appropriately.
// Left to the reader: imagine some "app" object with methods whose // implementations will be obvious from their use. var app = ...; // Catch clicks on the root-level element. document.body.addEventListener('click', function(event) { var tag = event.target; if (tag.tagName == 'A' && tag.href && event.button == 0) { // It's a left click on an <a href=...>. if (tag.origin == document.location.origin) { // It's a same-origin navigation: a link within the site. // Now check that the the app is capable of doing a // within-page update. (You might also take .query into // account.) var oldPath = document.location.pathname; var newPath = tag.pathname; if (app.capableOfRendering(newPath)) { // Prevent the browser from doing the navigation. e.preventDefault(); // Let the app handle it. app.render(newPath); history.pushState(null, '', path); } } } }); // Also transition when the user navigates back. window.onpopstate = function(event) { app.render(document.location.pathname); event.preventDefault(); };
Some additional notes:
-
This implementation relies on the URL as the primary way of referring to a state. This means that there's only a single code path for rendering that is shared both by a fresh navigation and by within-app navigations, reducing the chances of those codepaths skewing.
-
The
app.capableOfRendering
check can simply check your JavaScript URL routing code, and punt on anything that isn't rendered in client-side JavaScript. For example, if your site has a blog section that isn't really part of your app, by returningfalse
here the browser will just do a plain navigation to that part of the site. -
This implementation intercepts all link clicks, which you might think means it'll ruin links for actions that don't have URLs. For example, you might have a little "change" link next to a text field to flip it into an in-place editor, as described towards the end of my earlier post. My answer is that because these "links" don't have URLs — there's no reason to open them in a new tab, for example — they are not actually links, and you shouldn't use an
<a>
tag for them as you don't want the other behaviors (like showing the URL when you hover the link). You might be tempted to instead make this a<div>
with someonclick
behavior, but if you do so you're again attempting to be more clever than a browser, and that will likely have other consequences (for example, keyboard focus will be wrong). If it's really a button in its behavior you could instead just style a<button>
. -
This implementation is incomplete. Its button handling may be wrong for IE, it eats navigations to
#fragments
, it also should check the state of modifier keys, and it should handle clicks on nodes contained within an<a>
tag. These issues are small tweaks on the core idea; I left some out for brevity and mistakenly overlooked others. I think the right way to think about it is: whitelist in the one particular case you care about (left click with no modifier keys held on an<a>
tag that's on-origin for a known path with no fragment) and let the browser handle the rest. -
This implementation totally doesn't work with search engines (unless they are capable of running your site's JavaScript). That is a subject for another post. The above is sufficient if your site isn't intended to be crawlable (again, like Gmail).