DEV Community

Cover image for Turning a React app into an installable PWA with offline detection, service workers and theming.
Alex Gurr
Alex Gurr

Posted on • Edited on

Turning a React app into an installable PWA with offline detection, service workers and theming.

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.

Let's get started!

 

Contents

 

Setting Up Service Workers

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.

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



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


Enter fullscreen mode Exit fullscreen mode

Now simply run the function with registerServiceWorker();.

 
A finished index file should look something like this:



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();


Enter fullscreen mode Exit fullscreen mode

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.

 

Offline Detection & UI/UX

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):

 
offline-ux-gif

 
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

 

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.

 
index.js provides the default export for our component:



export { default } from './Offline';


Enter fullscreen mode Exit fullscreen mode

 
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!

 



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


Enter fullscreen mode Exit fullscreen mode

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.

 



import cx from 'classnames';
import './_offline.scss';

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


Enter fullscreen mode Exit fullscreen mode

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.

 



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}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

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.

 



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}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

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 and is a simple convenience hook for boolean manipulation.

 



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}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

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 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.

 



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}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

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.

 

The Styling

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



@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;
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

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.

 

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:



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


Enter fullscreen mode Exit fullscreen mode

 

Icons & Splash Screens

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.



{
  "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"
}


Enter fullscreen mode Exit fullscreen mode

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

 
Full breakdown of the manifest.json file can be found here. Your index.html file should link to this manifest, with a line similar to <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />.

 

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

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.

 

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:



<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" />


Enter fullscreen mode Exit fullscreen mode

 

Themes & Theme Colours

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.

 

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.

 

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!

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



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


Enter fullscreen mode Exit fullscreen mode

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



<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>


Enter fullscreen mode Exit fullscreen mode

 

Extras

Links

 

Thanks for reading! Feel free to leave feedback 🚀

Like my article and want more? Come and follow me on medium.

Top comments (6)

Collapse
 
dico_monecchi profile image
Adriano Monecchi • Edited

Great article! Thanks for sharing! Any ideas on how to set the theme_color or themeColor with next.js 14 new metadata API? I'm looking for a way to change it based on the selected theme like "light" and "dark" variants.

I'm aware there's not a method to dinamically change it in manifest.json other than trying different hacks along the way, as the Helmet one. However, I'm developing a pwa which makes use of the Window Controls Overlay feature for an extended title bar and window controls customization, which in turn takes the theme_color value as the default window controls background color.

The issue is I'm using shadcn ui and I've crafted an awesome dark theme for my project, but when using the pwa installed on desktop, if I switch theme to dark, the window controls remain with a light background.

I'm still looking for a propper solution. Any guidance or ideas are most appreciated!

Collapse
 
arielmeee profile image
arielmeee

What if i started my project without using the template for pwa. Should i install every packages and configure them manually? or there are other ways?

Collapse
 
stradivario profile image
Kristiqn Tachev

When you install C language is it writing everything automatically to create a complex application or you need to create it :D ?

What falls from a waterfall ?

U need to install everything automatically.
There is a tool called workbox this is the easiest way that i found

import {  precacheAndRoute } from 'workbox-precaching';
import {
  googleFontsCache,
  imageCache,
  offlineFallback,
  pageCache,
  staticResourceCache,
} from 'workbox-recipes';

precacheAndRoute(self.__WB_MANIFEST || []);

pageCache();

googleFontsCache();

staticResourceCache();

imageCache();

offlineFallback();

Enter fullscreen mode Exit fullscreen mode

Then you need to add registrator script inside index.html

  <script async>
    if ('serviceWorker' in navigator) {
      window.addEventListener('load', () => {
       navigator.serviceWorker.register('/service-worker.js');
      });
    }
  </script>
Enter fullscreen mode Exit fullscreen mode

Cheers

Collapse
 
alexgurr profile image
Alex Gurr

Using the pre built create react app scripts is probably better, as they contain code that will handle your web pack bundle files specifically with caching, whereas the above is kind of generic. The use of a script in the html also is kind of pointless in React land.

Your suggestion is good for vanilla html/js sites though 👍

Thread Thread
 
stradivario profile image
Kristiqn Tachev • Edited

Oh thanks man i didnt know that i am more like Vanilla, Angular, WebComponents, LitHTML developer.

Sorry if i intrude somehow the topic.

You can check one starter that i have created

github.com/rxdi/starter-client-sid...

Cheers!

Collapse
 
alexgurr profile image
Alex Gurr

Hey! I created a new app using the templates command in the guide and simply copied over the files/manually installed the packages needed. It only took about 5 minutes.