Syncing browser local storage with React state and the browser URL in a SPA

Gareth Cronin
5 min readAug 7, 2022

Last year when I was writing a series on my toolkit for building tiny web apps, I described an app that I made for checking which books on a Goodreads list or shelf were available at my local library. I focussed on the back-end that does some elaborate long-run batch processing to scrape the data from API-less websites.

I wrote the original front end for the app while I was a React novice, and while I am still a novice, I’ve learned enough lessons that I thought it was worth building a much better version. The new version of “What Can I Borrow?” is now up at the original URL, and it really is a lot better!

The new version — complete with faded results for books I’ve already read

One of the improvements I wanted to make was to take advantage of routing in React to make sure I could share library and list combinations with URLs, and the single page app would respond by “navigating” to the right state.

The front end state model

Even if a user isn’t logged in on one of my apps, I like the UI to make an effort to remember user selections between sessions. The pattern I usually use for this is to wrap a React useState hook so that it reads and writes to the browser’s local storage. That way on a page reload or a later visit, anything in the UI state can return to the last selected value (where that behaviour is logical and useful):

Local storage wrapper hook for useState()

My app’s selection state is pretty simple — there is a drop-down to select between two public library systems in New Zealand: Auckland and Wellington. Next to that, and with content that is dependent on the selection in the library drop-down is a drop-down to select from a range of list of books that have won awards, or been selected by Goodreads users or publishers.

The drop-downs

That pattern works fine until a user wants to share a library and list combination with another user — e.g. by sending them the URL on a messaging app. In the original app the URL doesn’t change as the library and list selections are changed, so there is nothing unique to share.

I wanted to expand my usual pattern so that the URL in the browser is synchronised with the state in React, and still backed by local storage. What I was aiming for looks like this:

Target state model

Getting started: an improved Create React App installation

I use Material UI aka MUI (React bindings for Material Design) for all my tiny web apps and until now I’ve been pulling MUI’s template direct from their site with a curl call piped into tar. That always feels a bit icky, and I was keen to try the official npx command for running it. I’ve recently added service workers to some of my other apps by manually copying bits of official template into existing projects, so I thought I’d also start from the Progressive Web App (PWA) template that Create React App provide:

npx create-react-app web — template cra-template-pwa

That leaves a nice clean React app all ready to go as a PWA with a basic service worker and it’s registration (after tweaking a line as per the comment in index.js). A user accessing the app over https will then automatically get the “add to home screen” or “install” button pop up so they can install it as proper PWA.

Once the template was up and working, I followed the getting started instructions at MUI. That consisted of installing a few NPM packages:

npm install @mui/material @emotion/react @emotion/styled @mui/icons-material

And this needs adding to the head of index.html:

<link rel=”stylesheet” href=”https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />

That’s it!

Using React Router DOM for the state model

The people at React Router like to make breaking changes between versions, which means tutorials for older versions break on copy and paste. I installed the latest version with npm install react-router-dom@6 and spent some time reading the documentation properly (!).

In version 6, the main routing block is a Routes element containing Route elements, where each element has a path attribute and an element attribute. The path follows the usual conventions of colon prefixed parameters and the element is a direct reference to a React element to render if the route matches.

If the user enters a library, and optionally a list, I want to pass those through to my LibraryListSelector component as URL parameters:

Handling a browser URL change

React Router provides hooks to read the parameters inside the component that has been routed to. I created a hook for the :library URL parameter and when this changes, I react (😁) by setting a local state variable called selectedLibrary, which uses the useLocalStorage wrapper hook to put an entry called “library” in the browser local storage. If there is a value for “library” already in local storage, then selectedLibrary will be initialised with it:

The MUI Select component (the drop-down) uses the selectedLibrary variable as its bound value.

For example, if the user navigates to wcib.apps.cronin.nz/auckland the useEffect above will fire and the value “auckland” will be pushed into selectedLibrary and also into the browser local storage variable “library”. The Select component will respond by showing the label for the value “auckland”.

Handling a drop-down selection change

To complete the state model I described earlier, I also needed this flow to work in reverse. If the user changes the drop-down from “Auckland” to “Wellington” then I want the browser URL to reflect that change. That way, bookmarking it or sharing it to someone else will behave as expected.

React Router exposes a useNavigate hook, which can be used to change the browser URL programatically within a component.

Default URL redirection

If the user browses directly to the base URL, then I want to start with whichever library is in local storage from last time — if there is a default value. React Router provides a Navigate element for programatic routing inside the route definition:

Side note: the infernal CloudFront+S3 403

Mixing SPAs with URL-based routing requires some fiddling. As I was writing this post I opened an incognito window in Chrome and directly browsed to wcib.apps.cronin.nz/auckland . My browser responded with a chunk of XML and a 403 “forbidden” error.

I host my SPA PWAs by creating an Amazon Web Services (AWS) CloudFront distribution in front of an S3 bucket. This little snippet has sorted the 403 for me many times. CloudFront reaches into S3 when a URL intended for a SPA is requested and if there is no object to match it, it’ll just 403. It’s easy to fix: it just requires a custom error response for any 403 that redirects to /index.html and returns a 200. The SPA can take care of managing erroneous sub-URLs.

--

--

Gareth Cronin

Technology leader in Auckland, New Zealand: start-up founder, father of two, maker of t-shirts and small software products