Uploading an image is straightforward, but what if you want to save it offline on the users mobile device without them knowing it wasn't eve

Offline Image Upload for a PWA

Uploading an image is straightforward, but what if you want to save it offline on the users mobile device without them knowing it wasn't ever sent to the server? Today we'll go over how we got this working in our React application.

I recently ran into an issue while building a progressive web app. What do I do if the app is offline and a user tries to upload an image? Well, I found a solution.

The framework I'm using is React, but this solution should work just as well in anything else you're using.

We'll be using two different web APIs to get this done the first is IndexedDB. The second is the FileReader. If you're not familiar with IndexedDB you can read up on it here. It's probably a good idea to at least briefly go thought that, but my goal will be to make this as self-explanatory as possible. 

The first step is to set up the database so that you can store the images. I do this in the componentDidMount method because it's is one of the first lifecycle methods to get triggered but if you're using something other than React you can do this anywhere that gets called A - on every page load and B - as soon as possible in the page load. As long as you're thinking about those two criteria you should be fine. The most fundamental requirement is that you setup the database before you make any requests to it but these two criteria are just as good. Anyway. To the code.

componentDidMount() {
  // connect to database
  if (window.indexedDB) {
    const indexedDB = window.indexedDB;
    const dbVersion = 1.0;

    const request = indexedDB.open("animal-diversity-db", dbVersion);

    request.onerror = event => {
      console.error('Error creating/accessing IndexedDB database');
      console.error(event.target.errorCode);
    }

    request.onsuccess = event => {
      console.info('Success creating/accessing IndexedDB database');
      const db = event.target.result;

      this.setState({
        db,
      });
    }

    request.onupgradeneeded = event => {
      const db = event.target.result;
      console.log('onupgradeneeded');

      db.createObjectStore('images');
      db.createObjectStore('upload-note-que');
      db.createObjectStore('delete-note-que');
    }
  } else {
    alert("Your browser doesn't support a stable version of IndexedDB. Offline image storage and other features may not work properly");

    this.setState({
      db: 'Unavailable',
    });
  }
}

So basically we're checking to see if indexedDB exists. As of today, the docs say that you should be checking for window.indexedDB instead of window.moxIndexedDB or webkitIndexedDB because the standard has moved to remove the browser prefixes and if it's possible that using the prefix will have your users be using an outdated version of IndexedDB.

We use indexedDB.open to connect to the database and use the onerror and onsuccess handlers to check for it either being created or bugging out. On successful creation, I use this.setState to basically give myself access to the DB across my app. If you aren't using react you can pass that variable somewhere else. If you're confused ask me in the comments.

The onupgradeneeded handler is necessary to set up the object stores. I have three object stores but you can do whatever your application needs. The image store is for saving the actual image binary to the database, and the other object stores are for storing some form objects while we don't have access to a database. So for the case we're talking about the image store will be all we need to save an image.

The else case is just for when the DB doesn't exist. I suggest informing your users if the DB is unavailable.

So that's basically it for setting the DB up. The next part will be all about putting the image into the database and retrieving it.

fileSelectedHandler = e => {
  const { db } = this.props;
  let { formData } = this.state;
  let { content, imageUUIDs } = formData;
  let { images } = content;

  const file = e.target.files[0];
  const fileSize = file.size;
  const htmlFormData = new FormData();

  const uuid = uuidv4();
  htmlFormData.append("id", uuid);
  htmlFormData.append("file", file, file.name);

  NoteManager.uploadImage(htmlFormData, fileSize)
    .then(image => {
      let newImages = [...images, image];
      let newImageUUIDs = [...imageUUIDs, uuid];

      this.setState({
        formData: {
          ...formData,
          content: {
            ...content,
            images: newImages,
          },
          imageUUIDs: newImageUUIDs,
        },
      });
    })
    .catch(err => {
      if (err.message === 'File must be less than 5mb') {
        this.setState({
          errorMessage: err.message,
        });
      }
      // If one of these three errors assume offline (or poor connection) and handle with local storage
      if (err.message === 'Unexpected end of JSON input' || err.message === 'Failed to fetch' || err.message === 'Timeout') {
        console.info(err.message);

        const transaction = db.transaction(['images'], 'readwrite');
        transaction.objectStore('images').put(file, uuid);
        transaction.objectStore('images').get(uuid).onsuccess = event => {
          const reader = new FileReader();
          reader.readAsDataURL(event.target.result);
          reader.onload = () => {
            const newImages = [...images, {src: reader.result}];
            const newImageUUIDs = [...imageUUIDs, `offline-${uuid}`];

            this.setState({
              formData: {
                ...formData,
                content: {
                  ...content,
                  images: newImages,
                },
                imageUUIDs: newImageUUIDs,
              },
            });
          };
        }
      }
    });
}

So some of this code is application-specific. Don't worry too much about the NoteManager.uploadImage. That's just a custom piece of code written to try and upload the image to the server. What we care about is when the server doesn't work.

We also care about the file selected handler so let's start there. The typical way to save an image is by using an input element like so

<input
  name="photos"
  type="file"
  onChange={this.fileSelectedHandler}
  ref={this.hiddenFileInput}
  style={{ display: 'none' }}
/>
<button type="button" onClick={() => this.hiddenFileInput.current.click()}>Upload</button>

So the only thing we need to know here is that we're using React, but this is a native HTML feature. We can attach a handler to an input element and it will give us the file to play with in the callback. You can see that in the code above where it says e.target.files[0]. The [0] is because we only allow the user to grab one file. So if you look at the fileSelecteHandler method we can see that we have access to the file and we try to upload it to the server. In the .then method we check to see if the server is available or not by checking the err.message for the two cases you see. Those two cases (and one more I can talk about if you're interested in the comments) will basically cover a poor internet connection.

So looking at that if statement we can see that the logic for saving the image offline in the event of a poor connection. So the first two lines are all you need to store the image.. these two

const transaction = db.transaction(['images'], 'readwrite');
transaction.objectStore('images').put(file, uuid);

The next few lines are for retrieving the image and displaying that image to the user. So in the onsuccess method of the database query we can read the results with the FileReader API. On successfully reading the file - in the onload handler we have access to reader.result which contains the actual image URL that we can use to display the image to our users.

Again the this.setState is more react junk that only pertains to my specific project.

Hopefully that was clear enough and hopefully this is helpful for the non-react users out there. Let me know in the comments if there is any confusion and I'd be happy to help.

Comments
Authors profile

Jonathan Emig

Subscribe for monthly technical content

* indicates required