Single Page Applications (SPAs) have a significant advantage over traditional multi-page applications in that they load assets when a client initially accesses the app. This means that routing can be very fast; the user doesn’t have to load a new HTML file every time they navigate within the application. But this advantage also creates a unique problem: after deployment, users will only have the latest version of your app once they refresh the site.
Create React App uses Webpack to generate new builds, and Webpack will automatically hash filenames to make sure the client doesn’t hold on to old assets. Assuming your cache is properly configured, all you need from the user is a refresh to ensure they get the changes from your last deployment.
Alerting the User
So how do we let the user know they should refresh the app? Well, you may have seen an increasingly popular pattern for handling this: the new version available alert.

Google Inbox (RIP) letting you know their team has deployed a new version
This pattern is used in many of Google’s products, including the Firebase Dev Console and Android Messages for web. It’s also used by my favorite budgeting app, YNAB. There are several ways to detect a version change on the frontend, including using service workers, regular calls to a server, and timers that run a check on index.html. All of these work, but they’re also fairly involved.
Because we use Firestore as our database on Datapage, we’re in a uniquely advantageous position when it comes to notifying our users about something. Each user already has an active connection to Firestore, and they’re already subscribing to changes in the database to display updates in real-time. If we can track the version of our app inside a Firestore document, the client can listen for a change in that document’s fields, and display a notification when the fields change in value.
Now we’ll need a component that listens for the change, and displays an alert. We use the Material-UI for building frontend components and react-redux-firebase for connecting to Firestore, but native Firestore web functions will work just as well here.
First, let’s create a new component that listens for a change in the version document of our config collection:
import React, { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { useFirestoreConnect } from 'react-redux-firebase'; export const VersionChangeRefresher = () => { const [versionChanged, setVersionChanged] = useState(false); useFirestoreConnect({ collection: 'config', doc: 'version', storeAs: 'appVersion' }); const version = useSelector( (state) => state.firestore.data.appVersion?.number ); const prevVersionRef = useRef(); const prevVersion = prevVersionRef.current; useEffect(() => { prevVersionRef.current = version; }); useEffect(() => { if (version && prevVersion && version !== prevVersion) { setVersionChanged(true); } }); return null }
We initialize a state variable called versionChanged
. We then connect to Firestore and subscribe to our version document and return the number
field. When version
changes, two side effects run. First, we set a ref with the value of the previous version so we can compare it to the new one. Then we compare the two versions and if they’re different, we update versionChanged
to true.
Now that we know when the version changes, all that’s left is the UI.
<Snackbar open={versionChanged}> <Alert variant="filled" severity="warning" classes={{ message: middleText, icon: middleIcon }} action={ <Button variant="outlined" onClick={() => forceRefresh()} style={{ minWidth: 100, color: 'white' }} > Refresh Now </Button> } > <Box display="flex" alignItems="center"> A new version of the app is available. Your browser will automatically refresh in 30 seconds. </Box> </Alert> </Snackbar>
Our Material-UI snackbar will open when versionChanged
is set to true
. Our refresh button calls on a function that is dead simple:
const forceRefresh = () => window.location.reload();
Feel free to stop here. But let’s say we wanted to get more aggressive than Google does in making sure every user is on the updated version of the app? We could display a countdown timer and force a refresh after a given interval.
Counting Down to Refresh
First, let’s add another state variable and two more side effects.
const [secondsSinceChange, setSecondsSinceChange] = useState(0); useEffect(() => { if (versionChanged && secondsSinceChange < 30) { const timer = setTimeout(() => { setSecondsSinceChange(prevState => prevState + 1); }, 1000); return () => clearTimeout(timer); } }); useEffect(() => { if (secondsSinceChange === 29) { forceRefresh(); } });
Our first effect will be triggered as soon as versionChanged
is true
and as long as secondsSinceChange
is less than 30. We initialize secondsSinceChange
as 0, and begin counting upwards, adding 1 to the value every second after our first side effect fires. As soon as secondsSinceChange
reaches 29, we force a refresh of the app.
This is a big improvement, but we should probably let the user know what’s going on. Let’s update our alert to display a progress spinner which will fill up as the timer counts to 30, with the number of seconds remaining in the middle.
<Snackbar open={versionChanged}> <Alert variant="filled" severity="warning" action={ <Button variant="outlined" onClick={() => forceRefresh()} > Refresh Now </Button> } icon={ <Box position="relative" display="inline-flex" style={{ height: 30 }}> <CircularProgress variant="determinate" value={secondsSinceChange * 3.33} size={30} /> <Box top={0} left={0} bottom={0} right={0} position="absolute" display="flex" alignItems="center" justifyContent="center" > <Typography variant="caption" component="div" > {30 - secondsSinceChange} </Typography> </Box> </Box> } > <Box display="flex" alignItems="center"> A new version of Datapage is available. Your browser will automatically refresh in 30 seconds. </Box> </Alert> </Snackbar>
We create a new Circular Progress MUI component and set its value to secondsSinceChange * 3.33
, converting our seconds counted to a value n/100, filling the circle up according to the Circular Progress component API. Inside the Circular Progress, we have an absolutely positioned numeric representation of how many seconds the user has before the app will refresh for them.
Automating Version Changes
This will work just fine, as long as you don’t forget to update your project’s version number by hand in Firestore each time you deploy a new version of the app. Let’s make it easier to not forget by automating the process.
Deployment processes vary widely, so I’ll just cover the process we use. The first step is to create an onRequest Cloud Function which will update our version number.
import * as functions from "firebase-functions"; import { db } from "../../admin"; export const updateVersion = functions.https.onRequest(async (req, res) => { try { const version = req.body.version; if (!version) { throw new Error("must specify version"); } db.collection("config") .doc("version") .set({ number: version }) .then((_) => { res.json({ result: "Version updated, deployment complete" }); }) .catch((err) => { throw new Error(err); }); } catch (err) { console.log(err); throw new Error("Update version failed"); } });
Now, all we need to do is to make a request with a parameter of the new version number as the body. In Datapage, we handle all of our deployments through NPM scripts from our dev machine. Here’s what our deployment script looks like:
npm run build:staging && npm run backup:staging && npm run transfer:staging
This single command calls each step of our deployment process, from building the project, to backing up the old deployment, to copying the build files to our server. Let’s add one more script which will be executed last, only after the other steps have been completed:
npm run "./updateVersion.sh $npm_package_version firebase-project-id"
We’re calling a shell script and passing it two arguments. The first argument is what you could call a “hidden” feature of NPM. Every entry in your package.json is accessible when running a script with npm run
, prefaced with npm_package
. Because we always update version
in our package.json when we deploy, we don’t have to change our deployment process to make sure we update Firestore with our new version number. The second argument is the Firebase project id that the Cloud Function should be run from.
Let’s take a look at that shell script:
echo "Updating version document in config of collection on $2 to ${1}..." && gcloud config set project $2 && gcloud functions call updateVersion --data '{ "version": "'${1}'"}' && echo "Version updated, deployment complete."
We’re making use of an overlooked feature of the Google Cloud CLI – it can call Cloud Functions directly. First, we set gcloud’s active project to the id we sent as our second argument. Then, we call the function and send back an object with a single field, version
, with the version number of our package.json.
Let’s recap how this all works:
- We commit our new version of the app, updating the version in package.json.
- We run
npm run deploy
- React builds the app for production, and transfers it to the server
- Node calls our script, which calls our Cloud Function, which updates Firestore with the new app version number
- Our component detects the change and fires a series of effects that display the alert: starting a countdown to an automatic browser refresh
The end result is that we won’t have to change anything about when and how we deploy, but we’ll be able to make some pretty safe assumptions about our users being up to date.