No description, website, or topics provided.
Branch: master
Clone or download
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
public
src resolved service worker not registering Feb 4, 2019
.gitignore
README.md Update README.md Feb 5, 2019
config-overrides.js resolved service worker not registering Feb 4, 2019
netlify.toml custom service worker set up Jan 20, 2019
package.json updated to React 16.8 w/ hooks Feb 9, 2019
yarn.lock updated to React 16.8 w/ hooks Feb 9, 2019

README.md

React PWA Boilerplate

The purpose of this repo is to get you up and running with a working sample progressive web app.

note: This is an opinionated set up. However, you can easily replace things like the state management and ui library with anything of your choosing

Table of Contents

Section Description
Project Structure Directory structure of the project
Service Worker Configuration of Service Worker using Work Box
State Management Setup Redux configuration using Easy Peasy
State Management DevTools Redux devtools configuration
UI Toolkit Abstraction Abstractions wrapping Material UI components
Routing Setup Configuration for routing and stubbed out Authenticated Routes
In Flight Request Optimization Abortable Network Request w/ RxJs
Netlify Config Notes on config for Netlify to support react router
iOS Support Notes on iOS and Safari support
Additional Resources Resources used to build out this Repo

note: This project was initially bootstrapped with Create React App.


Project Structure

react-pwa-boilerplate/
    ├── build/                          <-- final output destination for the entire app
    └── public/
        ├── _redirects                  <-- single page app routing for netlify
        └── manifest.json               <-- manifest for the pwa
    └── src/
        ├── components/
        ├── hooks/                      <-- custom hooks
        ├── models/                     <-- redux models
        ├── pages/                      <-- pages in the app that import components
        ├── routes/                     <-- index for all routes
        ├── util/                       <-- collection of utility functions
        ├── configureStore.js           <-- redux store configuration
        └── customServicerWorker.js     <-- where we define our caching strategies
    ├── config-overrides.js/            <-- build step config for workbox
    ├── netlify.toml/                   <-- single page app config for netlify
    └── package.json

Service Worker

  • caching strategies

    • pages are cached with a stale while revalidating strategy

    • all javascript, css, and html is cached with a stale while revalidating strategy

    • the manifest file is using a precache strategy

    • the core api is cached with a network first strategy

    • stale while revalidating caching strategy - diagram stalewhilerevalidating

    • network frist caching strategy - diagram networkfirst

    • some additional strategies otherstrategies

    • refer to Work Box for more strategies, as well as more thorough documentation.


  • build step configuration: this is already set up within the project in the config-overrides file.
// config-overrides.js
/* config-overrides.js */

const {
  rewireWorkboxInject,
  defaultInjectConfig
} = require('react-app-rewire-workbox');
const path = require('path');

module.exports = function override(config, env) {
  if (env === 'production') {
    console.log('Production build - Adding Workbox for PWAs');
    // Extend the default injection config with required swSrc
    const workboxConfig = {
      ...defaultInjectConfig,
      swSrc: path.join(__dirname, 'src', 'customServiceWorker.js'),
      importWorkboxFrom: 'local'
    };
    config = rewireWorkboxInject(workboxConfig)(config, env);
  }

  return config;
};

State Management Setup

  • the base configuration of the store is as follows:
// configureStore.js
import { composeWithDevTools } from 'redux-devtools-extension';
import { createStore } from 'easy-peasy';
import { model } from './models';

/**
 * model, is used for passing through the base model
 * the second argument takes an object for additional configuration
 */

const store = createStore(model, {
  compose: composeWithDevTools({ realtime: true, trace: true })
  // initialState: {}
});

export default store;
  • note: initialState is where you'd want to pass in the initial state of your auth model. So that if anyone refreshes the app, they don't get booted out of a private route
  • the base model being passed in:
// models/index.js
import { settingsModel } from './settings-model';
import { musicModel } from './music-model';

export const model = {
  ...settingsModel,
  ...musicModel
};
  • sample model:
// models/settings-model.js
import {
  checkLocalStorage,
  setInLocalStorage
} from '../util/localstorage-util';

// reyhdrate state from localstorage if it exist
const initialState = {
  currentThemeSelection: checkLocalStorage('currentThemeSelection', 'lite')
};

export const settingsModel = {
  settings: {
    ...initialState,
    updateSelectedTheme: (state, payload) => {
      // update the state
      state.currentThemeSelection = payload;

      // store the current theme selection in localstorage
      setInLocalStorage('currentThemeSelection', payload);
    }
  }
};
  • we can also bypass pulling in sagas or thunks by using easy-peasy's effect function
  • sample effect:
// models/music-model.js
..code

export const musicModel = {
  music: {
    ...initialState,
    getLyrics: effect(async (dispatch, payload, getState) => {
      const { artist, song } = payload;
      dispatch.music.updateIsLyricsNotFound(false);
      dispatch.music.updateIsLyricsLoading(true);
      dispatch.music.updateCurrentArtist(artist);
      dispatch.music.updateCurrentSong(song);
      requestStream = fetchLyrics$(payload).subscribe({
        next: ({ lyrics }) => dispatch.music.updateCurrentLyrics(lyrics),
        error: data => dispatch.music.updateIsLyricsNotFound(true),
        complete: data => dispatch.music.updateIsLyricsLoading(false)
      });
    })
  }
}

State Management DevTools

  • using tracing on actions within redux: this is already set up within project. All that's needed on your end is to adjust the configuration on your redux dev tools extension. As shown below redux-trace-demo

UI Toolkit Abstraction

  • a common and bet practice approach for using UI toolkits is to wrap commonly used components so that you're never handcuffed to a specific ui library

  • example:

// components/input-field-component.js
import React from 'react';
import TextField from '@material-ui/core/TextField';

export const InputField = ({
  input: { name, value, onChange, ...restInput },
  extraInputProps = {},
  meta,
  ...rest
}) => (
  <TextField
    {...rest}
    helperText={meta.touched ? meta.error : undefined}
    error={meta.error && meta.touched}
    onChange={onChange}
    value={value}
    name={name}
    InputProps={{
      ...restInput,
      ...extraInputProps
    }}
  />
);
  • we're also using react final form, so we're passing in additional props to this abstraction, as well as any other properties captured in ..rest

Routing Setup

  • base index file:
// routes/index.js
import React from 'react';
import { Route, Router, Switch } from 'react-router';

import { Layout } from '../components/layout-component';
import { Favorites } from '../pages/favorites-page';
import history from '../util/history-util';
import Home from '../pages/home-page';

/**
 * 👉 if authenticated routes are needed 👈
 *
 * import PrivateRoute from './PrivateRoute';
 *
 * const RestrictedArea = () => {
 * return (
 *  <Switch>
 *    <PrivateRoute path="/something-private" component={SomethingPrivate} />
 *  </Switch>
 * );
 *};
 **/

export const Routes = () => (
  <Router history={history}>
    <Layout>
      <Switch>
        <Route path="/favorites" component={Favorites} />
        <Route exact path="/" component={Home} />
        {/* <PrivateRoute path="/" component={() => RestrictedArea()} /> */}
      </Switch>
    </Layout>
  </Router>
);
  • if authenticated routes are needed:
// routes/PrivateRoute.js
import React from 'react';
import { Redirect, Route, withRouter } from 'react-router-dom';
import get from 'lodash/get';
import { useAuth } from '../hooks/auth-hook';

const PrivateRoute = ({ component: Component, ...rest }) => {
  const { auth, cachedAuth } = useAuth();
  const authPresent = !!get(auth, 'token', false);
  const cachedAuthExist = !!get(cachedAuth, 'token', false);
  return (
    <Route
      {...rest}
      render={props =>
        authPresent || cachedAuthExist ? (
          <Component {...props} />
        ) : (
          <Redirect to="/login" />
        )
      }
    />
  );
};

export default withRouter(PrivateRoute);
  • this will check to see if the user has a token or not and redirect properly

In Flight Request Optimization

  • with an observable pattern, we can cancel a flight mid request by unsubscribing from it

  • todo: add axios cancelToken

// services/music-service.js
..code
export const fetchLyrics$ = payload =>
 Observable.create(observer => {
   const artist = get(payload, 'artist');
   const song = get(payload, 'song');
   // trim possible white space and replace space between with + signs
   const cleanArtist = cleanQueryParam(artist);
   const cleanSong = cleanQueryParam(song);

   axios
     .get(`https://api.lyrics.ovh/v1/${cleanArtist}/${cleanSong}`)
     .then(response => {
       observer.next(response.data);
       observer.complete();
     })
     .catch(error => {
       observer.error(error);
     });
 });

// models/music-model -----------------------------------------------/
..code

let requestStream;

requestStream = fetchLyrics$(payload).subscribe({
 next: ({ lyrics }) => dispatch.music.updateCurrentLyrics(lyrics),
 error: data => dispatch.music.updateIsLyricsNotFound(true),
 complete: data => dispatch.music.updateIsLyricsLoading(false)
});

..more code

cancelLyricSearch: effect(async (dispatch, payload, getState) => {
 requestStream.unsubscribe();
 dispatch.music.updateIsLyricsLoading(false);
}),

// pages/home-page -------------------------------------------------/
..code

onClick={() => {
 form.reset();
 if (isLyricsNotFound) updateIsLyricsNotFound(false);
 if (isLyricsLoading) cancelLyricSearch();
}}
  • while this a very rudimentary example, this can be really powerful with search bars. Being able to cancel network request mid flight saves precious cpu cycles and other overhead fluff invovled with resolving request

Netlify Config

  • netlify.toml in the root of the app
[build]
  command = "yarn build"
  publish = "build"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
  • _redirects in the public directory
/*    /index.html   200

iOS Support

  • iPhone status bar currently only supports white, black, and translucent
  • all sizes of your app's icons must be declared within the index.html and present within your public directory appleicons
  • the same is true for splash screens.
  • push notifications are still not supported ¯_(ツ)_/¯
  • if the user has a ton of apps running on their iphone, safari may arbitrarily clear out your cache and terminate your service worker..

Additional Resources