URL Query String as SPA Initial State

Before starting, I would like to set the following quote as an axiom to be taken into account for a while

“Any website should always display the same content for a given URL, regardless any previous state”
― Someone with common sense

I can think of some exceptions, like session or browser preferences, that can make differ the content between users, but It should be true for each single user experience.

This is something important to have in mind when building Single Page Applications.

A common mistake I’ve encountered many times during development is when a page displaying a results view, offers filters to adjust the search. Once the users starts tweaking with those filters the results update but the URL keeps the same inducing a URL-State desynchronization, so if the user tries to reload the page or shares the URL, the web page will not display the filtered results. This is because the developer has directly updated the app state instead of use a router to keep the URL in sync with it.

Next I’m going to show some examples written using Vue and Vue Router

Warning: all this gists has been written directly from medium, I cannot assure they run without errors but I think that they capture the idea

Bad implementation

<template>
  <div>
    <ul>
      <li v-for="item in results" :key="item.id">
        {{ item.name  }}
      </li>
    </ul>

    <Checkbox v-model="filters.isNew" />

    <Pagination v-model="page" />
  </div>
</template>

<script>
export default {
  name: 'ResultsPage',
  data: () => ({
    results: [],
    page: 1,
    filters: {
      isNew: false
    }
  }),
  watch: {
    page (page) {
      this.fetchResults({ page })
    },
    filters: {
      deep: true,
      handler (filters) {
        this.fetchResults({ filters })
      }
    }
  },
  created () {
    this.fetchResults()
  },
  methods: {
    async fetchResults ({ filters = this.filters, page = this.page }) {
      const { data: results } = await SomeApiCall({ filters , page })
      this.results = results
    }
  }
}
</script>

Here we can see how the inputs Checkbox and Pagination are updating the data properties directly and then the watchers of them trigger the fetch of new results without updating the URL.

It’s wrong.

Good implementation

<template>
  <div>
    <ul>
      <li v-for="item in results" :key="item.id">
        {{ item.name  }}
      </li>
    </ul>

    <Checkbox
      :value="filters.isNew"
      @input="$router.push({ query: { ...$route.query, isNew: !$event }})"
    />

    <Pagination
      :value="page"
      @input="$router.push({ query: { ...$route.query, page: $event }})"
    />
  </div>
</template>

<script>
export default {
  name: 'ResultsPage',
  beforeRouteEnter (to, from, next) {
    next(vm => {
      vm.page = to.page.query.page || 1
      vm.filters.isNew = !!to.page.query.isNew
      vm.fetchResults()
    })
  },
  async beforeRouteUpdate  (to, from, next) {
    this.page = to.page.query.page || 1
    this.filters.isNew = !!to.page.query.isNew
    await this.fetchResults()
    next()
  },
  data: () => ({
    results: [],
    page: 1,
    filters: {
      isNew: false
    }
  }),
  methods: {
    async fetchResults () {
      const { data } = await SomeApiCall({
        filters: this.filters,
        page: this.page
      })
      this.results = data
    }
  }
}
</script>

On this example we use the navigation guards provided by Vue Router to hook into the cycle of the router so we can get the query params and sync our data with them

Even we could go further with it and get rid of the data properties to only use the params provided by the route so we don’t need to sync anything, like is shown in this last gist

<template>
  <div>
    <ul>
      <li v-for="item in results" :key="item.id">
        {{ item.name  }}
      </li>
    </ul>

    <Checkbox
      :value="!!$route.query.isNew"
      @input="$router.push({ query: { ...$route.query, isNew: !!$event } })"
    />
    <Pagination
      :value="$route.query.page || 1"
      @input="$router.push({ query: { ...$route.query, page: $event } })"
    />
  </div>
</template>

<script>
export default {
  name: 'ResultsPage',
  beforeRouteEnter (to, from, next) {
    next(vm => { vm.fetchResultsFromRoute(to) })
  },
  async beforeRouteUpdate  (to, from, next) {
    await this.fetchResultsFromRoute(to)
    next()
  },
  data: () => ({
    results: []
  }),
  methods: {
    async fetchResultsFromRoute ({ query: { isNew = false, page = 1 } = {}) {
      const { data } = await SomeApiCall({
        filters: { isNew },
        page
      })
      this.results = data
    }
  }
}
</script>

This way the browser will keep track of any changes on the state of the page and the user will have full functionality on Back/Forward actions and a fully shareable URL