Simple Svelte Routing with Reactive URLs

18 October 2020

Svelte doesn't have an official router yet. If all you need is to conditionally render a few components based on the URL, there's no reason to use one of the 10+ Svelte routers out there that do more than you asked for. We should be able to use native web APIs to do just that!

Here's a sneak-peak of what we'll acheive:

{#if $url.pathname === '/'}
  <h1>Home Sweet Home</h1>
{:else if $url.pathname === '/about'}
  <h1>About What?</h1>
{:else}
  <h1>404</h1>
{/if}

Or get your hands dirty at:

Table of Contents

The Problem

One of the main issue with the native web API is that there's no nice way of detecting URL changes, especially when working with the History API. At least for hash, there's the hashchange event.

When dealing with the History API, there's only the popstate event, which is usually triggered when navigating using the browser's forward and backward buttons. However, it will not be triggered when calling history.pushState or history.replaceState!

This leaves us no choice but to monkey-patch them. More info is discussed in this StackOverflow answer.

The Magic

Svelte has an awesome store feature, which allows us to easily create reactive variables by subscribing to changes. Combined with the auto-subscription syntax, it's ever easier to retrieve the URL and keeping it in sync with the browser.

Now we can create readable stores for hash and History API routing:

hash.js
import { readable } from "svelte/store"

export default readable(window.location.hash, set => {
  const update = () => set(window.location.hash)
  window.addEventListener("hashchange", update)
  return () => window.removeEventListener("hashchange", update)
})
history.js
import { readable } from "svelte/store"

export default readable(new URL(window.location.href), set => {
  const update = () => set(new URL(window.location.href))

  const originalPushState = history.pushState
  const originalReplaceState = history.replaceState

  history.pushState = function() {
    originalPushState.apply(this, arguments)
    update()
  }

  history.replaceState = function() {
    originalReplaceState.apply(this, arguments)
    update()
  }

  window.addEventListener("popstate", update)

  return () => {
    // Reverting the monkey-patches this way may be unsafe if there's external
    // code patching it too. The next section discusses more about this.
    history.pushState = originalPushState
    history.replaceState = originalReplaceState
    window.removeEventListener("popstate", update)
  }
})

Touching Up

Two code examples above should provide a nice starting point for your custom routing. But looking at history.js, it returns a reactive URL object, which also contains the URL hash. What if we can combine hash.js with it too?

url.js
import { derived, writable } from "svelte/store"

const href = writable(window.location.href)

const originalPushState = history.pushState
const originalReplaceState = history.replaceState

const updateHref = () => href.set(window.location.href)

history.pushState = function() {
  originalPushState.apply(this, arguments)
  updateHref()
}

history.replaceState = function() {
  originalReplaceState.apply(this, arguments)
  updateHref()
}

window.addEventListener("popstate", updateHref)
window.addEventListener("hashchange", updateHref)

export default derived(href, $href => new URL($href))

A few things have changed here, notably there's no readable store now. This is mainly because that there's no safe way to revert the monkey-patches as mentioned earlier. And since the URL store is likely to be used throughout the entire lifetime of the app, the store cleanup function is likely to never be called anyways.

There is also two stores now, href and the default exported URL store. This is to make sure that when navigating to the same URL (same href), the URL store does not re-compute a new URL object.

Server-side Rendering

Server-side rendering (SSR), a feature not many routers support, can also be easily implemented using a store. The gist is that instead of reading from window.location.href, we should be able to manually specify the current route to render, which basically translates to:

ssr.js
import { URL } from "url"
import { writable } from "svelte/store"

const url = writable(new URL("https://example.com"))

export default {
  subscribe: url.subscribe,
  set: href => url.set(new URL(href)),
}

Adapting this into url.js, we get:

url.js
import { derived, writable } from "svelte/store"

const isBrowser = typeof window !== "undefined"

const href = writable(isBrowser ? window.location.href : "https://example.com")

const URL = isBrowser ? window.URL : require("url").URL

if (isBrowser) {
  const originalPushState = history.pushState
  const originalReplaceState = history.replaceState

  const updateHref = () => href.set(window.location.href)

  history.pushState = function() {
    originalPushState.apply(this, arguments)
    updateHref()
  }

  history.replaceState = function() {
    originalReplaceState.apply(this, arguments)
    updateHref()
  }

  window.addEventListener("popstate", updateHref)
  window.addEventListener("hashchange", updateHref)
}

export default {
  subscribe: derived(href, $href => new URL($href)).subscribe,
  ssrSet: urlHref => href.set(urlHref),
}

Conclusion

And we've built ourself a reactive URL store that supports hash-based routing, history-based routing, and server-side rendering! Feel free to copy it in your next project and tweak it to your needs.

With reactive URLs, it's ever easier to route in our app:

App.svelte
<script>
  import url from './url'

  function handleLinkClick(e) {
    e.preventDefault()
    const href = e.target.href
    history.pushState(href, '', href)
  }
</script>

<nav>
  <a href="/" on:click={handleLinkClick}>Home</a>
  <a href="/about" on:click={handleLinkClick}>About</a>
  <a href="/404" on:click={handleLinkClick}>404</a>
</nav>

{#if $url.pathname === '/'}
  <h1>Home Sweet Home</h1>
{:else if $url.pathname === '/about'}
  <h1>About What?</h1>
{:else}
  <h1>404</h1>
{/if}

For more examples, check out:

Happy routing!


© Bjorn Lu 2021