tutorial // May 20, 2022

Caching Data Using URL Query Params in JavaScript

How to temporarily store data in a URLs query params and retrieve it and parse it for use in your UI.

Caching Data Using URL Query Params in JavaScript

Getting Started

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start. Before you run joystick start, we need to install one package, query-string:

Terminal

cd app && npm i query-string

This package will help us to parse and set our query params on the fly. After that's installed, go ahead and start up the server:

Terminal

joystick start

After this, your app should be running and we're ready to get started.

Adding some global CSS

In order to better contextualize our demo, we're going to be adding CSS throughout the tutorial. To start, we need to add some global CSS that's going to handle the overall display of our pages:

/index.css

* {
  margin: 0;
  padding: 0;
}

*, *:before, *:after {
  box-sizing: border-box;
}

body {
  font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
  font-size: 16px;
  background: #fff;
}

.container {
  width: 100%;
  max-width: 800px;
  margin: 15px auto;
  padding: 0 15px !important;
}

@media screen and (min-width: 768px) {
  .container {
    margin-top: 50px;
  }
}

By default when you open up this file, only the CSS for the body tag will exist. The specifics here don't matter too much, but what we're doing is adding some "reset" styles for all HTML elements in the browser (removing the default browser CSS that adds extra margins and padding and changes how elements flow in the box model) and a .container class that will allow us to easily create a centered <div></div> for wrapping content.

That's all we need here. We'll be adding more CSS later at the individual component level. Next, we need to wire up a route for a dummy page that we'll use to test out our query params.

Adding a route to redirect to for testing params

In a Joystick app, all routes are defined on the server in one place: /index.server.js. Let's open that up now and add a route for a dummy page we can redirect to and verify our query params work as expected:

/index.server.js

import node from "@joystick.js/node";
import api from "./api";

node.app({
  api,
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/index/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "/listings/:listingId": (req, res) => {
      res.render("ui/pages/listing/index.js");
    },
    "*": (req, res) => {
      res.render("ui/pages/error/index.js", {
        layout: "ui/layouts/app/index.js",
        props: {
          statusCode: 404,
        },
      });
    },
  },
});

When you ran joystick start earlier from the root of your app, this is the file that Joystick started up. Here, the node.app() function starts up a new Node.js application using Express.js behind the scenes. To Express, the routes object being defined on the options object passed to node.app() is handed off.

By default on this object, we see the / and * routes being defined. Above, we've added a new route /listings/:listingId. For our app, we're building a fake real estate search UI where users will be able to customize some search parameters and view listings.

Here, we're creating the route for a fake—it won't load any real data, just some static dummy data—listing page that the user will be able to redirect to. The idea is that we'll set some query params on the URL on the / (index) route and then allow the user to click on a link to this /listings/:listingId page. When they do, the query params we set will "go away." When they go back, we expect those query params to restore.

Inside of the route here, we're calling to a function on the res object, res.render() which is a special function that Joystick adds to the standard Express res object. This function is designed to take the path to a Joystick component in our app and render it on the page.

Here, we're assuming that we'll have a page located at /ui/pages/listing/index.js. Let's go and wire that up now.

Wiring up a fake listing page

This one is quick. We don't care too much about the page itself here, just that it exists for us to redirect the user to.

/ui/pages/listing/index.js

import ui from '@joystick.js/ui';

const Listing = ui.component({
  css: `
    .listing-image img {
      max-width: 100%;
      width: 100%;
      display: block;
      height: auto;
    }

    .listing-metadata {
      margin-top: 25px;
    }

    .listing-metadata .price {
      font-size: 28px;
      color: #333;
    }

    .listing-metadata .address {
      font-size: 18px;
      color: #888;
      margin-top: 7px;
    }

    .listing-metadata .rooms {
      font-size: 16px;
      color: #888;
      margin-top: 10px;
    }
  `,
  render: () => {
    return `
      <div class="container">
        <div class="listing-image">
          <img src="/house.jpg" alt="House" />
        </div>
        <div class="listing-metadata">
          <h2 class="price">$350,000</h2>
          <p class="address">1234 Fake St. Winter, MA 12345</p>
          <p class="rooms">3br, 2ba, 2,465 sqft</p>
        </div>
      </div>
    `;
  },
});

export default Listing;

Here we create a Joystick component by calling the .component() function defined on the ui object we import from the @joystick.js/ui package. To that function, we pass an object of options to define our component.

Starting at the bottom, we have a render() function which tells our component the HTML we'd like to render for our component. Here, because we don't need a functioning page, we just return a string of plain HTML with some hardcoded data. Of note, the house.jpg image being rendered here can be downloaded from our S3 bucket here. This should be placed in the /public folder at the root of the project.

In addition to this, like we hinted at earlier, we're adding in some CSS. To do it, on a Joystick component we have the css option that we can pass a string of CSS to. Joystick automatically scopes this CSS to this component to help us avoid leaking the styles to other components.

That's it here. Again, this is just a dummy component for helping us test the query parameter logic we'll set up in the next section.

Wiring up a fake search UI with filters and results page

While there's a lot going on in this component, the part we want to focus on is the logic for managing our query params. To get there, first, let's build out the skeleton UI for our component and then pepper in the actual logic to get it working.

Though we didn't discuss it earlier, here, we're going to overwrite the existing contents of the /ui/pages/index/index.js file:

/ui/pages/index/index.js

import ui from '@joystick.js/ui';

const Index = ui.component({
  css: `
    .search {
      padding: 20px;
    }

    header {
      display: flex;
      margin-bottom: 40px;
      padding-left: 20px;
    }

    header > * {
      margin-right: 20px;
    }

    .options label {
      margin-right: 10px;
    }

    .options label input {
      margin-right: 3px;
    }

    .listings ul {
      display: grid;
      grid-template-columns: 1fr;
      list-style: none;
    }

    .listings ul li {
      position: relative;
      padding: 20px;
      border: 1px solid transparent;
      cursor: pointer;
    }

    .listings ul li:hover {
      border: 1px solid #eee;
      box-shadow: 0px 1px 1px 2px rgba(0, 0, 0, 0.01);
    }

    .listings ul li a {
      position: absolute;
      inset: 0;
      z-index: 5;
    }

    .listing-image img {
      max-width: 100%;
      width: 100%;
      display: block;
      height: auto;
    }

    .listing-metadata {
      margin-top: 25px;
    }

    .listing-metadata .price {
      font-size: 24px;
      color: #333;
    }

    .listing-metadata .address {
      font-size: 16px;
      color: #888;
      margin-top: 7px;
    }

    .listing-metadata .rooms {
      font-size: 14px;
      color: #888;
      margin-top: 7px;
    }

    @media screen and (min-width: 768px) {
      .search {
        padding: 40px;
      }

      .listings ul {
        display: grid;
        grid-template-columns: 1fr 1fr;
      }  
    }

    @media screen and (min-width: 1200px) {
      .listings ul {
        display: grid;
        grid-template-columns: 1fr 1fr 1fr 1fr;
      }  
    }
  `,
  render: () => {
    return `
      <div class="search">
        <header>
          <input type="text" name="search" placeholder="Search listings..." />
          <select name="category">
            <option value="house">House</option>
            <option value="apartment">Apartment</option>
            <option value="condo">Condo</option>
            <option value="land">Land</option>
          </select>        
          <select name="status">
            <option value="forSale">For Sale</option>
            <option value="forRent">For Rent</option>
            <option value="sold">Sold</option>
          </select>
          <div class="options">
            <label><input type="checkbox" name="hasGarage" /> Garage</label>
            <label><input type="checkbox" name="hasCentralAir" /> Central Air</label>
            <label><input type="checkbox" name="hasPool" /> Pool</label>
          </div>
          <a href="#" class="clear">Clear</a>
        </header>
        <div class="listings">
          <ul>
            <li>
              <a href="/listings/123"></a>
              <div class="listing-image">
                <img src="/house.jpg" alt="House" />
              </div>
              <div class="listing-metadata">
                <h2 class="price">$350,000</h2>
                <p class="address">1234 Fake St. Winter, MA 12345</p>
                <p class="rooms">3br, 2ba, 2,465 sqft</p>
              </div>
            </li>
          </ul>
        </div>
      </div>
    `;
  },
});

export default Index;

Above, we're getting the core HTML and CSS on page for our UI. Again, our goal is to have a pseudo search UI where the user can set some search params and see a list of results on the page. Here, we're building out that core UI and styling it up. After we add this, if we visit http://localhost:2600/ (ignore the 2605 in the screenshot below—this was just for testing while writing) in our browser, we should see something like this:

bHbu2RZpt0mmU3Dy/AorUBQlEApOyBYhl.0
Rendering the core search UI as plain HTML/CSS.

Next, let's wire up a "default" state for our search UI (we're referring to everything in the header or top portion of the UI as the "search UI").

/ui/pages/index/index.js

import ui from '@joystick.js/ui';

const Index = ui.component({
  state: {
    search: '',
    category: 'house',
    status: 'forSale',
    hasGarage: false,
    hasCentralAir: false,
    hasPool: false,
  },
  css: `...`,
  render: ({ state }) => {
    return `
      <div class="search">
        <header>
          <input type="text" name="search" value="${state.search}" placeholder="Search listings..." />
          <select name="category" value="${state.category}">
            <option value="house" ${state.category === 'house' ? 'selected' : ''}>House</option>
            <option value="apartment" ${state.category === 'apartment' ? 'selected' : ''}>Apartment</option>
            <option value="condo" ${state.category === 'condo' ? 'selected' : ''}>Condo</option>
            <option value="land" ${state.category === 'land' ? 'selected' : ''}>Land</option>
          </select>        
          <select name="status" value="${state.status}">
            <option value="forSale" ${state.status === 'forSale' ? 'selected' : ''}>For Sale</option>
            <option value="forRent" ${state.status === 'forRent' ? 'selected' : ''}>For Rent</option>
            <option value="sold" ${state.status === 'sold' ? 'selected' : ''}>Sold</option>
          </select>
          <div class="options">
            <label><input type="checkbox" name="hasGarage" ${state?.hasGarage ? 'checked' : ''} /> Garage</label>
            <label><input type="checkbox" name="hasCentralAir" ${state?.hasCentralAir ? 'checked' : ''} /> Central Air</label>
            <label><input type="checkbox" name="hasPool" ${state?.hasPool ? 'checked' : ''} /> Pool</label>
          </div>
          <a href="#" class="clear">Clear</a>
        </header>
        <div class="listings">
          <ul>
            <li>
              <a href="/listings/123"></a>
              <div class="listing-image">
                <img src="/house.jpg" alt="House" />
              </div>
              <div class="listing-metadata">
                <h2 class="price">$350,000</h2>
                <p class="address">1234 Fake St. Winter, MA 12345</p>
                <p class="rooms">3br, 2ba, 2,465 sqft</p>
              </div>
            </li>
          </ul>
        </div>
      </div>
    `;
  },
});

export default Index;

On a Joystick component, we can pass a state option which is assigned to an object of properties that we want to assign to our component's internal state by default (i.e., when the component first loads up). Here, we're creating some defaults that we want to use for our search UI.

The important part here, back down in the render() function, is that we've added an argument to our render() function which we anticipate is an object that we can destructure to "pluck off" specific properties and assign them to variables of the same name in the current scope/context. The object we expect here is the component instance (meaning, the component we're currently authoring, as it exists in memory).

On that instance, we expect to have access to the current state value. "State" in this case is referring to the visual state of our UI. The values on the state object are intended to be a means for augmenting this visual state on the fly.

Here, we take that state object to reference the values to populate our search UI. We have three types of inputs in our UI:

  1. input which is a plain text input used for entering a string of search text.
  2. select which is used for our listing "category" and "status" inputs.
  3. checkbox which is used for our amenities checkboxes.

Down in our HTML, we're referencing these values using JavaScript string interpolation (a language-level feature for embedding/evaluating JavaScript inside of a string). We can do this because the value we return from our component's render() function is a string.

Depending on the type of input we're rendering, we utilize the corresponding state value slightly differently. For our plain text search input, we can just set a value attribute equal to the value of state.search.

For our select <select> inputs we set both a value attribute on the main <select> tag as well as a conditional selected attribute on each option in that <select> list (important as if we don't do this, the current value of the input won't appear as selected without this attribute).

Finally, for our checkbox inputs, we conditionally add a checked attribute value based on the corresponding state value for each input.

This gives us the fundamentals of our UI. Now, we're ready to wire up the capturing of changes to our search UI and storing them as query params in our URL.

Capturing search filters as query params

Now that we have our base UI set, we can start to manage our query params. To do it, we're going to add some JavaScript event listeners to our UI so we can grab the latest values as they're set by the user:

/ui/pages/index/index.js

import ui from '@joystick.js/ui';
import queryString from 'query-string';

const Index = ui.component({
  state: { ... },
  methods: {
    handleUpdateQueryParams: (param = '', value = '') => {
      const existingQueryParams = queryString.parse(location.search);
      const updatedQueryParams = queryString.stringify({
        ...existingQueryParams,
        [param]: value,
      });

      window.history.pushState('', '', `?${updatedQueryParams}`);
    },
    handleClearQueryParams: (component = {}) => {
      window.history.pushState('', '', `${location.origin}${location.pathname}`);
      component.methods.handleSetStateFromQueryParams();
    },
  },
  css: `...`,
  events: {
    'keyup [name="search"]': (event, component = {}) => {
      component.methods.handleUpdateQueryParams('search', event.target.value);
    },
    'change [name="category"]': (event, component = {}) => {
      component.methods.handleUpdateQueryParams('category', event.target.value);
    },
    'change [name="status"]': (event, component = {}) => {
      component.methods.handleUpdateQueryParams('status', event.target.value);
    },
    'change [type="checkbox"]': (event, component = {}) => {
      component.methods.handleUpdateQueryParams(event.target.name, event.target.checked);
    },
    'click .clear': (event, component = {}) => {
      event.preventDefault();
      component.methods.handleClearQueryParams();
    },
  },
  render: ({ state }) => {
    return `
      <div class="search">
        ...
      </div>
    `;
  },
});

export default Index;

Above, we've added two new properties to our component's options: events and methods. Focusing on events, here, Joystick helps us to listen for JavaScript DOM events on elements rendered by our component. Each event is defined as a property on the object passed to events where the property name is a string describing the type of DOM event to listen for and the element inside of our component to listen for the event on.

To the property, we assign a function that should be called when that event is detected on the specified element. Here, we've added listeners for each of our search-related inputs (save for the checkbox inputs which we just listen for generically on inputs with a type of checkbox).

Notice that the odd duck out here is the search text input. Here, we want to listen for the keyup event on the input as we want to capture each change to the input (if we listen for a change event like we do the others, it will only fire after the user has "blurred" or clicked out of the input).

Inside of all event listeners (save for the last which we'll cover in a bit), we're calling to component.methods.handleUpdateQueryParams(). To an event listener's callback function, Joystick passes two values: event and component. event being the raw JavaScript DOM event that fired and component being the current component instance (similar to what we saw down in render())—the = {} part after component here is us defining a default value—a core JavaScript feature—to fallback to in the event that component isn't defined (this will never be true as it's automatic—consider adding this a force of habit).

From the component instance, we want to access a method defined on the methods object (where we can store miscellaneous methods on our component instance). Here, we're calling to a method defined above, handleUpdateQueryParams().

Up top, we've added an import of the queryString package we installed earlier which will help us to parse the existing query params in the URL and prepare our values for addition to the URL.

Inside of handleUpdateQueryParams(), we need to anticipate existing query params in our URL that we're adding to, so, we begin by grabbing any existing query params and parsing them into an object with queryString.parse(). Here, location.search is the global browser value that contains the current query string like ?someParam=value. When we pass that value to queryString.parse() we get back a JavaScript object like { someParam: 'value' }.

With that, we create another variable updatedQueryParams which is set to a call to queryString.stringify() and passed an object that we want to convert back into a query string like ?someParam=value.

On that object, using the JavaScript ... spread operator, we first "unpack" or spread out any existing query params and then immediately follow it with [param]: value where param is the name of the param we want to update (passed as the first argument to handleUpdateQueryParams()) and value being the value we want to set for that param—set via the second argument passed to handleUpdateQueryParams(). The [param] syntax here is using JavaScript bracket notation to say "dynamically set the property name to the value of the param argument."

If we look down in our event handlers to see how this is called, we pass the param either as a string or in the case of our checkbox inputs, as the event.target.name value or the name attribute of the checkbox firing the event.

With updatedQueryParams compiled, next, to update our URL, we call to the global window.history.pushState() passing an update we want to apply to the URL. Here, history.pushState() is a function that updates our browser's history but does not trigger a browser refresh (like we'd expect if we manually set the location.search value directly).

Admittedly, the API for history.pushState() is a bit confusing (as noted in this MDN article on the function here). For the first two values, we just pass empty strings (see the previous link on MDN if you're curious about what these are for) and for the third argument, we pass the URL we want to "push" onto the browser history.

In this case, we don't want to modify the URL itself, just the query params, so we pass a string containing a ? which denotes the beginning of query params in a URL and the value returned by queryString.stringify() in updatedQueryParams.

That's it. Now, if we start to make changes to our UI, we should see our URL start to update dynamically with the input values of our search UI.

Before we move on, real quick, calling attention to the click .clear event listener and subsequent call to methods.handleClearQueryParams(), here we're doing what the code suggests: clearing out any query params we've set on the URL when the user clicks on the "Clear" link at the end of our search UI.

To do it, we eventually call to history.pushState(), this time passing the combination of the current location.origin (e.g., http://localhost:2600) with the current location.pathname (e.g., / or /listings/123). This effectively clears out all query params in the URL and strips it down to just the base URL for the current page.

After this, we're calling to another method we've yet to define: methods.handleSetStateFromQueryParams(). We'll see how this takes shape in the next—and final—section.

Reloading search filters when page loads

This part is fairly straightforward. Now that we have our query params in our URL, we want to account for those params whenever our page loads. Remember, we want to be able to move away from this page, come back, and have our search UI "reload" the user's search values from the URL.

/ui/pages/index/index.js

import ui from '@joystick.js/ui';
import queryString from 'query-string';

const Index = ui.component({
  state: { ... },
  lifecycle: {
    onMount: (component = {}) => {
      component.methods.handleSetStateFromQueryParams();
    },
  },
  methods: {
    handleSetStateFromQueryParams: (component = {}) => {
      const queryParams = queryString.parse(location.search);
      component.setState({
        search: queryParams?.search || '',
        category: queryParams?.category || 'house',
        status: queryParams?.status || 'forSale',
        hasGarage: queryParams?.hasGarage && queryParams?.hasGarage === 'true' || false,
        hasCentralAir: queryParams?.hasCentralAir && queryParams?.hasCentralAir === 'true' || false,
        hasPool: queryParams?.hasPool && queryParams?.hasPool === 'true' || false,
      });
    },
    handleUpdateQueryParams: (param = '', value = '') => { ... },
    handleClearQueryParams: (component = {}) => {
      window.history.pushState('', '', `${location.origin}${location.pathname}`);
      component.methods.handleSetStateFromQueryParams();
    },
  },
  css: `...`,
  events: { ... },
  render: ({ state }) => {
    return `
      <div class="search">
        ...
      </div>
    `;
  },
});

export default Index;

Last part. Above, we've added an additional property to our component options lifecycle and on the object passed to that, we've defined a function onMount taking in the component instance as the first argument.

Here, we're saying "when this components mounts (loads up) in the browser, call to the methods.handleSetStateFromQueryParams() function. The idea being what you'd expect: to load the current set of query params from the URL back onto our component's state when the page loads up.

Focusing on handleSetStateFromQueryParams(), the work here is pretty simple. First, we want to get the query params as an object queryParams by calling to queryString.parse(location.search). This is similar to what we saw earlier, taking the ?someParam=value form of our query params and converting it to a JavaScript object like { someParam: 'value' }.

With that object queryParams, we call to component.setState() to dynamically update the state of our component. Here, we're setting each of the values we specified in our component's default state earlier. For each value, we attempt to access that param from the queryParams object. If it exists, we use it, and if not, we use the JavaScript or || operator to say "use this value instead." Here, the "instead" is just falling back to the same values we set on the default state earlier.

Note: an astute reader will say that we can just loop over the queryParams object and selectively edit values on state so that we don't have to do fallback values like this. You'd be right, but here the goal is clarity and accessibility for all skill levels.

That's it! Now when we set some search values and refresh the page, our query params will remain and be automatically set back on our UI if we refresh the page. If we click on the fake listing in our list to go to its detail page and then click "back" in the browser, our query params will still exist in the URL and be loaded back into the UI.

Wrapping up

In this tutorial, we learned how to dynamically set query parameters in the browser. We learned how to create a simple, dynamic search UI that stored the user's search params in the URL and when reloading the page, how to load those params from the URL back into our UI. To do it, we learned how to use the various features of a Joystick component in conjunction with the query-string package to help us encode and decode the query params in our URL.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode