Turning a React app into an installable PWA with offline detection, service workers and theming.
Tags: react, webdev, javascript
Published: 2021-10-13 10:02:52

Recently I decided to take the dive into making my web app progressive. Some of the benefits are excellent caching, sped up page load times and the ability for a user to install it "natively".

There are definitely some gotchas and other interesting tidbits which I'll also be covering below.

**I'm using React, so I'll assume you are too.** If you want to jump in to the code, it's all in the [mixmello GitHub repo](https://github.com/alexgurr/mixmello).

Let's get started!

&nbsp;

## Contents

* [Setting Up Service Workers](#chapter-1)
* [Offline Detection & UI/UX](#chapter-2)
* [Icons & Splash Screens](#chapter-3)
* [Themes & Theme Colours](#chapter-4)
* [Extras](#chapter-5)

&nbsp;
### Setting Up Service Workers <a name="chapter-1"></a>

Create-react-app provides us a couple of excellent service worker files to help us get started. They automatically configure lots of useful things like caching your webpack output. They'll pretty much contain everything we need for our PWA.

You can get these files by running `npx create-react-app my-app --template cra-template-pwa`.

This will give you two files you can move into your project, `serviceWorkerRegistration.js` and `service-worker.js`. Add these into `/src` of your project (or use the new project provided by the command). I'm not going to deep dive into these files today as they are extremely well documented via comments.

&nbsp;
Now we actually need to register our service worker on launch. In your app `index` file, import the service worker.

```js
import { register as registerServiceWorker } from './serviceWorkerRegistration';
```

Now simply run the function with `registerServiceWorker();`.

&nbsp;
A finished index file should look something like this:

```js
import React from 'react';
import ReactDOM from 'react-dom';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

registerServiceWorker();
```

Service workers will only register/run in a production build, unless specifically enabled (see create-react-app documentation in the extras section below). This is because hot-reloading and service worker caching don't mix very well! This means you won't see the service worker running in `Dev tools > Application > Service Workers`.

&nbsp;
### Offline Detection & UI/UX <a name="chapter-2"></a>

Offline detection is not specifically a service worker/PWA feature, however, PWAs are 'offline first', meaning it's a good idea to have code to handle offline/online state.

In my application, I decided to add a little bubble that comes down from the top of the screen and block the page. See what it looks like below (might take a few seconds to load):

&nbsp;
![offline-ux-gif](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ettrfq8x5frs54kwqb0r.gif)

&nbsp;
To make a good user & developer experience for this feature - 

- It should be a higher order component we can wrap round our whole app, for single responsibility and no code duplication
- It should prevent the user from scrolling when open
- It should be able to detect when the app is online/offline in real time
- It should be clear what's happening

&nbsp;
#### The Component

Let's make a new folder, `Offline`. Where you put it is up to you. In my app, it's in `src/common/components`. I'm using SCSS, but you can continue to use whatever framework your app is using.

Create 3 new files, `index.js`, `Offline.js` and `_offline.scss`.

&nbsp;
`index.js` provides the default export for our component:

```js
export { default } from './Offline';
```

&nbsp;
`Offline.js` is our main component. The component is comprised of two main bits of functionality. 1) The window event handlers to handle network state changes and 2) the actual JSX/HTML itself. Here I'm using React 17 and hooks but you could retrofit this to a class component if needed.

Let's start building!

&nbsp;
```js
export default function Offline({ children }) {
  return (
    <>
      <div className="offline" />
      {children}
    </>
  );
}
```

We've instantiated a new component and rendered it inside a fragment, because we don't want to add an additional layer/container above our app's children.

&nbsp;
```js
import cx from 'classnames';
import './_offline.scss';

export default function Offline({ children }) {
  return (
    <>
      <div className="offline" />
      <div className={cx('offline__overlay')} />
      {children}
    </>
  );
}
```

Now we have our styles import and an overlay div that will fade out the background. I'm using a library called `classnames` to chain classes but you don't have to use it. Later on, we'll conditionally change the overlay styles bases on our online/offline state.

&nbsp;
```js
import cx from 'classnames';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  return (
    <>
      <div className="offline">
				<div className="offline__content">
					<OfflineLogo />
					<div className="offline__text">
						<Text subHeading className="mt-0 mb-5">You're not online</Text>
						<Text className="mt-0 mb-0">Check your internet connection.</Text>
					</div>
				</div>
      <div className={cx('offline__overlay')} />
      {children}
    </>
  );
}
```

Now we're adding some content to our little offline bubble. `Text` is a component wrapper for text elements like `<p>` . I've created a dedicated SVG logo for offline, but you can use whatever you like in it's place. The `mt-x` helper classes are for margin which I cover in my other article [here](https://dev.to/alexgurr/creating-a-style-utility-file-with-some-scss-magic-5fg7).

&nbsp;
```js
import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
	const previousOnline = usePrevious(online);
	
	useEffect(() => {
		window.addEventListener('online', setOnline);
		window.addEventListener('offline', setOffline);
		
		return () => {
			window.removeEventListener('online', setOnline);
			window.removeEventListener('offline', setOffline);
		};
	}, []);
  
  return (
    <>
      <div className="offline">
				<div className="offline__content">
					<OfflineLogo />
					<div className="offline__text">
						<Text subHeading className="mt-0 mb-5">You're not online</Text>
						<Text className="mt-0 mb-0">Check your internet connection.</Text>
					</div>
				</div>
      <div className={cx('offline__overlay')} />
      {children}
    </>
  );
}
```

We've added the logic that makes it do something! We have two state variables, `online` which will reflect our network state (boolean) and `previousOnline` which allows us to prevent the overlay appearing on first load which we'll set up shortly.

The  `useEffect` hook only runs once (on first render) and sets up our window event listeners. The function that's returned will be run on page unload and will clear those same listeners. `useBooleanState` is a hook provided by [webrix](https://webrix.amdocs.com/docs/hooks/usebooleanstate/) and is a simple convenience hook for boolean manipulation.

&nbsp;
```js
import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
	const previousOnline = usePrevious(online);
	
	useEffect(() => {
		window.addEventListener('online', setOnline);
		window.addEventListener('offline', setOffline);
		
		return () => {
			window.removeEventListener('online', setOnline);
			window.removeEventListener('offline', setOffline);
		};
	}, []);
  
  return (
    <>
     <div
    		className={cx(
					'offline',
					'animate__animated',
					'animate__faster',
    			
    			// This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
					'animate__${online ? 'slideOutUp' : 'slideInDown'}'
				)}
				style={previousOnline === online && online ? { display: 'none' } : void 0}
    	>
				<div className="offline__content">
					<OfflineLogo />
					<div className="offline__text">
						<Text subHeading className="mt-0 mb-5">You're not online</Text>
						<Text className="mt-0 mb-0">Check your internet connection.</Text>
					</div>
				</div>
			<div className={cx('offline__overlay', { 'offline__overlay--visible': !online })} />
      {children}
    </>
  );
}
```

Now we'll actually use our `online` variable to do some cool stuff! Firstly, we're adding a conditional class to our overlay, which we'll style later.

Next, we're making it a bit more shiny with animation! I've used [animate.css](https://animate.style/) to make the bubble slide in and out of the screen. It provides us some animation classnames we can use.

Finally, we've added a conditional style to our container, to cover the initial load when we're connected. This prevents the bubble from appearing and immediately sliding out of view.

&nbsp;
```js
import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
	const previousOnline = usePrevious(online);
	
  useEffect(() => {
		if (!online) { return void disableBodyScroll(document.body); }
		
		enableBodyScroll(document.body);
	}, [online]);
  
	useEffect(() => {
		window.addEventListener('online', setOnline);
		window.addEventListener('offline', setOffline);
		
		return () => {
			window.removeEventListener('online', setOnline);
			window.removeEventListener('offline', setOffline);
		};
	}, []);
  
  return (
    <>
     <div
    		className={cx(
					'offline',
					'animate__animated',
					'animate__faster',
    			
    			// This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
					'animate__${online ? 'slideOutUp' : 'slideInDown'}'
				)}
				style={previousOnline === online && online ? { display: 'none' } : void 0}
    	>
				<div className="offline__content">
					<OfflineLogo />
					<div className="offline__text">
						<Text subHeading className="mt-0 mb-5">You're not online</Text>
						<Text className="mt-0 mb-0">Check your internet connection.</Text>
					</div>
				</div>
			<div className={cx('offline__overlay', { 'offline__overlay--visible': !online })} />
      {children}
    </>
  );
}
```

Last but not least, let's lock scrolling. Remember the earlier requirement? When the overlay and bubble are open the user shouldn't be able to scroll in the background. For this, we use a library called `body-scroll-lock` and simply toggle the lock in our new `useEffect` hook.

&nbsp;
#### The Styling

Styling in SCSS is pretty simple. Here's how we can get the result above:

```css
@import 'vars';

.offline {
  position: fixed;
  top: 0;
  z-index: 4;
  left: calc(50% - 200px);
  width: 400px;
  padding-top: 40px;

  @media only screen and (max-width: $mobile-width) {
    padding-top: 20px;
  }

  @media only screen and (max-width: 500px) {
    padding-top: 20px;
    width: calc(100% - 40px);
    left: 20px;
  }

  &__content {
    padding: 15px 20px;
    background: white;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 6px;

    > svg {
      height: 50px;
      width: auto;
      margin-right: 20px;
    }
  }

  &__overlay {
    position: fixed;
    z-index: 3;
    background: rgba(0, 0, 0, 0.8);
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    opacity: 0;
    transition: opacity 0.5s ease-in-out;
    pointer-events: none;

    &--visible {
      opacity: 1;
      pointer-events: unset;
    }
  }
}
```

Parts worth talking about are:

- Hardcoded `right %`, instead of `translate`. `animate.css` uses transforms to animate, so we need a different approach to center it horizontally.
- `@import 'vars'` - this is just a file full of SCSS variables. The media query variable is just a pixel value.
- `padding: top` instead of an actual `top` value - `animate.css` uses `transform: translateY(-100%)` on the container when sliding it out. If we use a top value, the component won't slide completely out of view. If we give it padding instead, we are making the component larger and therefore will all slide out, but still have the gap from the top of the screen.

&nbsp;
#### Using It In Our App

You can use the component wherever you want, but I recommend as high as possible. In mine, it's in the app `index` file:

```js
ReactDOM.render(
  <React.StrictMode>
    <Offline>
    	<App />
    </Offline>
  </React.StrictMode>,
  document.getElementById('root')
);
```

&nbsp;
### Icons & Splash Screens <a name="chapter-3"></a>

#### Manifest.json

The manifest file is used to tell platforms how we want our PWA to behave. `create-react-app` creates a `manifest.json` file automatically for us, in the `public` folder.

```json
{
  "short_name": "name",
  "name": "name",
  "description": "description",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "background_color": "#fff"
}
```

`short_name` - the title that's displayed on smaller areas, such as on home screens

`name` - the full title of the app

`description` - app description

`icons` - these are icons used on an android home screen or for PWA desktop apps on desktop.**These are not used on iOS PWAs** (see gotchas below)

`start_url` - entry point to your application. For standard React apps, this will be root, or `.`

`display` - how should your app be displayed within a PWA container? `standalone` will render full screen and give a more *native* experience

`background_color` - loading screen background colour (such as on a splash screen). This is not the background colour of your app when loaded.

`theme_color` - this dictates the color of the status bar at the top of the app, however I choose to just use the theme `<meta>` tag in  `index.html` as I can dynamically change it (see themes below).

For my app, I took my app's logo and turned it into a macOS-esque rounded icon, such as:

![before-after-icon](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3nkqilbyicnzsmixy93p.jpg)

&nbsp;
Full breakdown of the `manifest.json` file can be found [here](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json). Your `index.html` file should link to this manifest, with a line similar to `<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />`.

&nbsp;
#### iOS & Gotchas 

iOS **still** doesn't handle PWAs very well. Your manifest file will be pretty much ignored, other than to tell iOS you support PWAs. PWAs are only supported via **Safari**.

iOS **does not support transparency** on icons. It'll render a black background behind your icon if it's a png. You should make special icons for iOS, with a coloured background (mine's white), which looks like:

![ios-preview](https://puu.sh/IhYap/c9fc0bbdd4.png)

To use it, we'll need the link `<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/ios-touch-icon.png">` in our `index.html` file.

&nbsp;
#### Splash Screens

To show a splash screen on iOS when the app's loading, you'll need a series of html code lines in `index.html`. Unfortunately, you'll need a different sized image per supported resolution:

```html
<link href="%PUBLIC_URL%/splash/iphone5_splash.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphone6_splash.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphoneplus_splash.png" media="(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonex_splash.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonexr_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonexsmax_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipad_splash.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro1_splash.png" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro3_splash.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro2_splash.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
```

&nbsp;
### Themes & Theme Colours <a name="chapter-4"></a>

As mentioned before, we'll control theme through `index.html` and not using `manifest.json`. Find out more about `theme-color` and what it looks like in action, [here](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color).

&nbsp;
#### Static Theme Colour

Static theme colours are easy. Simply include this line in your `index.html` file. `<meta name="theme-color" content="#ffffff" />` . `create-react-app` provides this by default.

&nbsp;
#### Dynamic Theme Colour

In your app, you might have different page colours. For example, in my app, the homepage is green, but the rest are white. I wanted the theme-color to change based on where I was. When a Modal window opens, the theme-color becomes black.

For this, you'll need a library called `react-helmet`. Helmet allows us to modify the `<head>` of our document from within our components. Sweet!

&nbsp;
To do this, simply include the `<Helmet>` element in any of your components:

```js
<Helmet><meta name="theme-color" content="#000000" /></Helmet>
```

&nbsp;
We can actually extend the `Offline.js` component we built earlier to make the status bar black:

```js
<div
	className={cx(
		'offline',
		'animate__animated',
		'animate__faster',
     
    // This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
		'animate__${online ? 'slideOutUp' : 'slideInDown'}'
	)}
	style={previousOnline === online && online ? { display: 'none' } : void 0}
>
  
  // The line below changes the theme dynamically, but only when we're offline
	{!online && <Helmet><meta name="theme-color" content="#000000" /></Helmet>}
   
	<div className="offline__content">
		<OfflineLogo />
		<div className="offline__text">
			<Text subHeading className="mt-0 mb-5">You're not online</Text>
			<Text className="mt-0 mb-0">Check your internet connection.</Text>
		</div>
	</div>
</div>
```

&nbsp;
### Extras <a name="chapter-5"></a>

#### Links

- [Google PWA deep dive](https://developers.google.com/web/ilt/pwa)
- [Create-react-app PWA documentation](https://create-react-app.dev/docs/making-a-progressive-web-app/)
- [Apple PWA documentation](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html)

&nbsp;
### Thanks for reading! Feel free to leave feedback 🚀

Like my article and want more? Come and follow me on [medium](https://medium.com/@alexgurr).