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.
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:
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:
input
which is a plain text input used for entering a string of search text.select
which is used for our listing "category" and "status" inputs.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.