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
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.
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
-
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
-
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;
};
- 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)
});
})
}
}
- 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
-
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
- 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
-
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.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
- 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 - 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..
- Create a react app pwa notes
- Service workers
- Workbox caching Strategies
- Lyric search api
- Handling check for updated service worker
- Material UI theme generator
- Background fetch strategy explained
- Background fetch strategy documentation
- Preload Components that kickoff network request on mount w/ react.lazy
- PWA's are now supported within Google's Play Store!!!
- Handling Network Request w/ RxJs
- Tips for imroving your PWA iOS user's experience