When you update your code, it all happens so smoothly in development. You do some updates, the browser refreshes, and there's the latest build. And then you push to production, and everyone sees it - right?
That's what I thought for a long time. And with static sites and server-side rendering, that is what happens.
But it's not what happens with a single-page application (SPA) like ReactJS.
Why single-page applications don't update
Let's say your SPA gets accessed from app.html
, available on your website at the /app
route. A user wants to log in to your app and use it, so they head over to /app
, and their browser loads up app.html
.
Now there's a bunch of client-side code for the browser to load. Your app.bundle.js
and probably various other code bundles (if you use code-splitting) as they navigate around your application.
People might keep your single-page application (SPA) open for weeks, months - even years! Maybe they love it. Or find it useful. Or they've just forgotten. I know I've got tabs open on my phone from over 12 months ago - don't judge me, I'm a tab hoarder, ok.
And that means they never reload that app.html
page - it's called a single-page application, after all! Even when they navigate around - underneath it all, they are still on a single HTML page.
And unless the user closes the tab and reopens it, navigates away and back to the /app
page, or refreshes their browser - they won't get any new updates you have deployed. Bummer!
Why is this a problem?
You're probably excited to get new features into the hands of your users. You've probably made a ton of improvements. And you might have fixed a few bugs along the way too. You want to get these updates into the hands of your users!
You might send an email about all these super-duper new features, and your users might wonder why they can't see them.
You might have made back-end (server-side) changes to your API, or other services you have used might have introduced breaking changes. You've updated your app to work with the changes, but if users are still on the old version, they might start to get errors, and things will break for them.
Not ideal!
How can you force clients to refresh?
In a perfect world, your users would know when you've updated your front-end code, and refresh the page to get the latest version! But that's never going to happen. If your users aren't very tech-savvy, they might not even know how to refresh a browser. And even if they are, they won't know when you've deployed an update.
Here's what I decided to do instead:
- Add a version header to every API response from the server
- Add an app version to the client-side code
- Check if the versions match when the client receives a server response, and if it doesn't refresh the browser on the next app route change (so as not to refresh when the user might be in the middle of something like filling in a form)
In the code examples below I'm using:
But even if you don't use the same frameworks, you should be able to adapt the general concepts for versioning any SPA.
Add a version header to every server response
Most single-page applications talk to a server. Either to save data, fetch data, or both. When people log in, load a list or fill in a form, it all goes to the back-end for validation and response.
If you control the server, either because you run a server for the back-end or because you write serverless functions, you can let the client know it's out of date in your responses to API calls.
I use the version from my package.json
file. If you use npm to run your server (e.g. npm run start
) and build your app (e.g. npm run build-app
) then the package.json version is exposed as a handy environment variable process.env.npm_package_version
.
Here's the middleware I created in NodeJS using Express to add a custom version header to my API responses.
// add app version header to all responses (for checking for updates)
app.use(function (req, res, next) {
res.set('x-version', process.env.npm_package_version);
next()
});
Add the version to your single-page application
I'm using webpack to build my ReactJS application, so I could use the DefinePlugin
to get the server version into my build as an environment variable, here's how that looks in webpack.config.js
:
module.exports = {
entry: {
"app": './app/app.js'
},
// ...
plugins: [
new webpack.DefinePlugin({
'process.env.VERSION': JSON.stringify(process.env.npm_package_version)
})
],
};
Now I can access the version as process.env.VERSION
in ReactJS.
Check the server response on the client
Now both my server and the client know what version they are, we can get to the fun part - checking if the versions match.
Because my front-end and back-end code live in the same repository (monorepo), both npm_package_version
's should match. If the server version is newer, I know there's a new version of the app to load*.
*I guess that's not strictly true - as I may not have updated the app code and just updated something on the server, but I'm not too worried about that. You could create a different environment variable for the app_version
and use that instead, but I didn't want to have two version variables to keep updating.
When you get a response from the server now, you can check what version it is, because you've added the x-version
custom header.
You don't really want to manually add this check to every API call, so you need a way to intercept all API responses to make the check. I use axios for my HTTP client to handle my API calls, so I use a response interceptor to make the check.
// Add a response interceptor
axios.interceptors.response.use(function(response) {
if (response.headers['x-version'] > process.env.VERSION) { // if server version newer
window.localStorage.setItem('version-update-needed', 'true'); // set version update item so can refresh app later
};
return response; // continue with response
});
You might notice that I set a version-update-needed
local storage item rather than refreshing the page immediately. Chances are, your user has requested some info or sent some data - they are waiting for a response. Since your server is responding, it's done the work. If an update is available, you don't want to refresh the page and interrupt what the user is waiting for or working on - that's a bad user experience.
For now, we'll quietly check if there's an update available and continue with the response. If there is an update, set the local storage, and you can handle it later.
Forcing a browser refresh to reload the single-page application
Now you know if there's a new version of your application for the client to load - let's load it!
You've got a couple of options to consider:
- A pop-up that tells users there's a new version with a button to refresh the page
- Do it automatically on route changes within your app
The pop-up idea is pretty cool. It lets users know about updates, and they can choose when to refresh the page.
You could force the refresh by only closing the pop-up when they hit refresh. Or it could be optional. If you have a sidebar, you could permanently show the message on every page until they update - or only show it on the dashboard, for example.
When they click the button, refreshing the page will reload your app.html
page and get the latest version of any code you deployed.
function onClick() {
window.localStorage.removeItem('version-update-needed'); // remove the storage object
window.location.reload(); // refresh the browser
};
Or, if you want the refresh to happen automatically, you can check if there's an update needed whenever the user navigates to a new location in your app. Since you'll only have a new app version from time to time, and the user is changing routes anyway - and this is when reloads usually happen - it's fairly unobtrusive.
I used ReactRouters useLocation
hook to achieve this by creating a custom useVersionCheck
hook:
import React from "react";
import { useLocation } from "react-router-dom";
// hook to check the app version on route change
function useVersionCheck() {
// check if a version update (refresh) needed on route change
let location = useLocation();
React.useLayoutEffect(() => {
// if there is an update available and no state passed to route
if (!location.state && window.localStorage.getItem('version-update-needed')) {
window.localStorage.removeItem('version-update-needed'); // remove the storage object
window.location.reload(); // refresh the browser
}
}, [location]);
};
You can put this hook near the top of the application (in a component that's always rendered), and it will check for a version update on every route change.
If you only check for the existence of a version-update-needed
local storage item (like I have) - rather than its value - make sure you remove the version-update-needed
item before refreshing the browser.
What if you don't have a server?
What if you don't control the API responses and can't add a custom header for the version? For example, if you use other third-party APIs for your authentication and data?
In this case, I'd maybe create a serverless function or other API endpoint that returns the app version.
Then you could add a local storage item called something like version-last-checked
and set that as the current timestamp when the app is first loaded. On every route change in the app, check if the timestamp is over 24 hours old (or whatever frequency you choose), and if it is, call the version API to check the version.
Each time you call the API, update that local storage item so your app only requests the API version check at the specified frequency.
You can then run the same checks as before to see if a new version is available - and either show a pop-up to your users to reload the app or refresh the page on the next route change.