24/11/2019, 09:43

React PWA + Feathers API

Today we will extend on one of the pervious apps we've worked before. We used react hooks to create this Todo application That app used localStorage to save the state. We will transform it to a full blown PWA with help of Feathers.js. If you want to brush up on your skills on React Hooks or ...

Today we will extend on one of the pervious apps we've worked before. We used react hooks to create this Todo application

That app used localStorage to save the state. We will transform it to a full blown PWA with help of Feathers.js.

If you want to brush up on your skills on React Hooks or Feathers.js Please feel free to refer to these previous articles.

Intro to Feathers.js

Feathers hooks

Todo using React Hooks

Because we've generated our app using create-react-app, we don't have to do a lot to turn our app offline capable. create-react-app by default comes with all the settings in place conveniently. But, for development purpose, it's disabled by default.

To enable, navigate to src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();   <<=============== We need to change this

Find the serviceWorker.unregister() (at the bottom) line and replace it with

serviceWorker.register()

Now, our app is primed to register the serviceWorker on pageload. But, there are some gotchas.

// This optional code is used to register a service worker.
// register() is not called by default.

// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.

// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA

const isLocalhost = Boolean(  <<============ This will block sw from loading in development env.
  window.location.hostname === 'localhost' ||
    // [::1] is the IPv6 localhost address.
    window.location.hostname === '[::1]' ||
    // 127.0.0.1/8 is considered localhost for IPv4.
    window.location.hostname.match(
      /^127(?:.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
    )
);

export function register(config) {
  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    // The URL constructor is available in all browsers that support SW.
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
    if (publicUrl.origin !== window.location.origin) {
      // Our service worker won't work if PUBLIC_URL is on a different origin
      // from what our page is served on. This might happen if a CDN is used to
      // serve assets; see https://github.com/facebook/create-react-app/issues/2374
      return;
    }

    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;

      if (isLocalhost) {
        // This is running on localhost. Let's check if a service worker still exists or not.
        checkValidServiceWorker(swUrl, config);

        // Add some additional logging to localhost, pointing developers to the
        // service worker/PWA documentation.
        navigator.serviceWorker.ready.then(() => {
          console.log(
            'This web app is being served cache-first by a service ' +
              'worker. To learn more, visit https://bit.ly/CRA-PWA'
          );
        });
      } else {
        // Is not localhost. Just register service worker
        registerValidSW(swUrl, config);
      }
    });
  }
}

function registerValidSW(swUrl, config) {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        if (installingWorker == null) {
          return;
        }
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // At this point, the updated precached content has been fetched,
              // but the previous service worker will still serve the older
              // content until all client tabs are closed.
              console.log(
                'New content is available and will be used when all ' +
                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
              );

              // Execute callback
              if (config && config.onUpdate) {
                config.onUpdate(registration);
              }
            } else {
              // At this point, everything has been precached.
              // It's the perfect time to display a
              // "Content is cached for offline use." message.
              console.log('Content is cached for offline use.');

              // Execute callback
              if (config && config.onSuccess) {
                config.onSuccess(registration);
              }
            }
          }
        };
      };
    })
    .catch(error => {
      console.error('Error during service worker registration:', error);
    });
}

function checkValidServiceWorker(swUrl, config) {
  // Check if the service worker can be found. If it can't reload the page.
  fetch(swUrl)
    .then(response => {
      // Ensure service worker exists, and that we really are getting a JS file.
      const contentType = response.headers.get('content-type');
      if (
        response.status === 404 ||
        (contentType != null && contentType.indexOf('javascript') === -1)
      ) {
        // No service worker found. Probably a different app. Reload the page.
        navigator.serviceWorker.ready.then(registration => {
          registration.unregister().then(() => {
            window.location.reload();
          });
        });
      } else {
        // Service worker found. Proceed as normal.
        registerValidSW(swUrl, config);
      }
    })
    .catch(() => {
      console.log(
        'No internet connection found. App is running in offline mode.'
      );
    });
}

export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then(registration => {
      registration.unregister();
    });
  }
}

The way create-react-app designed it's service worker, in order for it to work, we have to use it as production environment. You can just modify isLocalhost to false, and continue working.

const isLocalhost = false;

But a much better way is, to run it from production env. For that run

npm i -g serve # if you don't have it installed already

npm run build  # generate the build folder
serve -s build # serve !

Now to production server should be running at localhost:5000. Let's test.

Because we will use Feathers.js this is extremely easy part. Will take us less than 5 minutes to be up and running with the API.

So, let's create a new folder, and generate the app

mkdir feathers-todo
feathers generate app

select the default options (we disabled authentication for the sake of bravety)

now, lets create the todo service

feathers generate service

Just doing this we will have all the necessary api for our app. For now, keep the feathers server running by running npm start.

Let's focus back on our React Todo and make it work with the feathers api.

We'll use axios for this. Run this and install axios on your code

npm i -s axios

Open App.js and import axios on top.

....
import axios from 'axios';

Now, let's see our old App.js

if (localStorage.getItem('todoStore') !== null) {
    store = JSON.parse(localStorage.getItem('todoStore'));
  } else {
    store = [{
      item: "example todo",
      isCompleted: false
    }]
  }

We were checking if our browser's localStorage had previous todos stored, and reading that as our store object.

Let's change that to read from our feathers api

axios.get('localhost:3030/todos') // 3030 is feathers app running
.then(function (response) {
  setTodos(response.data.data);
})
.catch(function (error) {
  console.log(error);
});

Fire up the react server and let's run.

We are blocked by the CORS security countermeasures. No worries, it's just a one line change to make it work. Open the package.json file, and add the "proxy": "http://localhost:3030" line at the bottom.

{
  "name": "react-todo",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "axios": "^0.19.0",
    "font-awesome": "^4.7.0",
    "react": "^16.9.0",
    "react-dom": "^16.9.0",
    "react-scripts": "3.1.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "proxy": "http://localhost:3030"     <<============ Add this
}

This will tell react app to redirect any path it can't recognize to the proxy path. Easy as that. That being done. let's modify our code, we can just call axios.get('/todos') now, no need to define full path.

 axios.get('/todos')
 .then(function (response) {
   setTodos(response.data.data);
 })
 .catch(function (error) {
   console.log(error);
 });

Create

Time to modify our create todo method. The previous method only saved data to localStorage.

const addItem = text => {
  const newTodos = [...todos, { item: text }];
  setTodos(newTodos);
  localStorage.setItem('todoStore', JSON.stringify(newTodos));
}

Let's change it to actually do api call. After we get response from the server, we'll call the setTodos(todo) method and update the UI.

  const addItem = text => {
    const todo = { item: text, isCompleted: false };
    const newTodos = [...todos, todo ];
    axios.post('/todos', todo)
    .then(function (response) {
      setTodos(newTodos);
    })
    .catch(function (error) {
      console.log(error);
    });
  }

Update

const complete = index => {
  const newTodos = [...todos];
  const todo = newTodos[index];
  todo.isCompleted = !todo.isCompleted
  setTodos(newTodos);
  axios.put(`/todos/${todo._id}`, todo)
  .then(function (response) {
    setTodos(newTodos);
  })
  .catch(function (error) {
    console.log(error);
  });
}

Remove

const removeItem = index => {
  const newTodos = [...todos];
  const removedTodo = newTodos.splice(index, 1);

  axios.delete(`/todos/${removedTodo[0]._id}`)
  .then(function (response) {
    setTodos(newTodos);
  })
  .catch(function (error) {
    console.log(error);
  });
}

And we're done !

React App

Feathers App

0