Syncing browser local storage with React state and the browser URL in a SPA
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!
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):
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.
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:
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.