The Daily TIL

March 6, 2019 by DaveDavenetlifyjavascriptformsoffline

Handle Offline Form Submissions

One of the key benefits to turning your website into a progressive web app is the ability to have a rich offline experience. I used Gatsby for the site I was building, and they provide a plugin gatsby-plugin-offline that generates a service worker for storing the site content and serving it when offline.

This works great to make sure users can always access and use the site. But now we need to consider the other implications of offline support. In my case, the main purpose of the site is data collection. So if I have enabled users to interact with the site when offline, I need to support data collection when offline as well.

Example

This means that we need to add:

  • the ability to determine when we are online/offline
  • some sort of local storage
  • logic for retrieving data from storage and retroactively submitting the data

I am using Netlify to host the site, and I am using their built-in form handling for data collection. The form itself doesn’t need to change. The submission process just needs to be updated.

For general form submission, I use a onSubmit handler like:

handleSubmit: ({ setWasSent }) => e => {
	e.preventDefault();
	const form = serializeForm(e.target);

	fetch('/', {
		method: 'POST',
		headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
		body: formEncode(form),
	});
},

The first thing that needs to be added is a check to see if the user is online before sending the data. The easiest way of determining this is using navigator.onLine:

handleSubmit: ({ setWasSent }) => e => {
	e.preventDefault();
	const form = serializeForm(e.target);
	if (navigator.onLine) {
		fetch('/', {
			method: 'POST',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
			body: formEncode(form),
		});
	} else {
		// store the data
	}

	setWasSent(true);
},

Next we need some way to store the data in the case that the user is not online. There are plenty of options to choose from for data storage. Since I’m not expecting large volumes of data, I will just use window.localStorage, which provides a simple key/value store. For large volumes of data, window.indexedDB seems to be the preferred approach.

export const storeData = (data, keyPrefix) => {
  if (typeof Storage !== 'undefined') {
    const entry = {
      time: new Date().getTime(),
      data: data,
    };
    localStorage.setItem(
      `${keyPrefix}_${localStorage.length}`,
      JSON.stringify(entry)
    );
    return true;
  }
  return false;
};

To identify the records that I’m storing, I add a known prefix followed by a unique number. This way I can find all records that that match my prefix when I need to read the entries. You can inspect the stored data in the browser by going to the Application -> Storage -> Local Storage section in dev tools.

Extracting the original form submission logic into sendData, the handler can be updated to look like:

handleSubmit: ({ setWasSent }) => e => {
	e.preventDefault();
	const form = serializeForm(e.target);

	if (navigator.onLine) {
		sendData(form);
	} else {
		storeData(form, 'myFormName');
	}

	setWasSent(true);
},

Cool, the form now either sends data directly or it stores the data. Now we need a way to send the stored data once we are back online. The easiest way of doing this is adding an event listener for the online and/or load events. I’ll put this on the main page so the load event fires when the app is opened:

if (typeof window !== 'undefined') {
  window.addEventListener('online', () => checkStorage('myFormName'));
  window.addEventListener('load', () => checkStorage('myFormName'));
}

The checkStorage function should first do a check to make sure the user is online (since we’re also triggering it from the load event). If online, we need to look for records with a key that matches the prefix we used to store the data. Then we can send each of those records and remove them from storage:

export const checkStorage = keyPrefix => {
  if (
    typeof Storage !== 'undefined' &&
    typeof navigator != 'undefined' &&
    navigator.onLine
  ) {
    for (var key in localStorage) {
      if (key.startsWith(keyPrefix)) {
        var item = localStorage[key];

        const entry = item && JSON.parse(item);

        if (entry) {
          var data = entry.data;
          sendData(data);
          localStorage.removeItem(key);
        }
      }
    }
  }
};

Future Improvements

The checkStorage handler is a good example of something that the service worker should be doing in the background. Having that work tied to the page is not ideal. And the branching logic in how we handle data in the onSubmit handler could be cleaned up. A sensible approach might be to ALWAYS store the data in the onSubmit handler and to have the service worker responsible for all data submission. That would be pretty cool.

References