The Airship Blog

Offline-First Approach for Mobile Apps: React Native and Apollo

Written by Austin Jones | Dec 2, 2020 6:00:00 AM

As a developer, I'm familiar with creating and testing mobile apps on a simulator or a physical device connected to my home or work office wifi. Connection is never an issue during development, so it's easy to not think about a very real problem that all apps will face.

What happens when your app is used in the real world and there's no guarantee of decent internet connection? Does the app become unusable in poor connection situations? Do you handle specific portions of the app differently depending on the connection status?

But there's no need to let the panic set in just yet (save that for deploying to the app store!). There are steps you can take to ensure your app works across various connection capabilities. Best of all? Addressing these potential issues will add that extra level of polish that makes apps more user friendly.

Design

Before diving straight into code, there needs to be designs in place that are able to visually tell the user what is currently happening with the app and the connection. Imagine we have an app that loads into a home tab that is a feed of information ranging from text to media. The app normally makes an api call to get all of the feed data, but without a connection how does it behave? Did the text, photo, and videos get cached from the last time the app was opened, or did it only keep the text and have image placeholders?

If we look at some of the most popular apps, connection loss can be handled in many different ways. The apps vary in how explicit they convey connection statuses. Some may display a small banner saying “No internet connection”, “Feed failed to load”, or just won’t show the feed at all and only display an error message. It is important to understand how you are wanting to handle the look as it may change your approach to tackling the issue.

Interactions

Another important aspect of your app is how will interactions be handled when there is no internet connection? You don’t want a user to navigate to a screen only to be seeing a loader running to no end. Maybe the user can’t upload a piece of content, but the app doesn’t tell them what is going on. Messages may need to be displayed saying a certain action cannot be performed at the moment, or that they should try refreshing again later. Think through what is important for your app to still function if faced with little to no internet connection.

If we can’t successfully communicate to the user what is currently happening, this can easily lead to frustration and a failure in creating an offline solution.

Real-World Situation

In a project for a construction company I am building at Airship, a user in the mobile app is able to create notes on certain items in the app. The notes are specific to the user that wrote them and only viewable by that user. There is a web app that also has the same functionality and you can access the notes from there or the mobile app. The expectation of the app is that it will work in low to no connectivity situations since it will be used at construction sites. This key feature is needed to function and perform in the above situation, so I decided to go the route of allowing changes to be made locally and get synced to the server once connection is re-established.

Let’s Talk Code

There are many different ways and technologies to consider when approaching this problem. We decided on using GraphQL for the server and this app for many reasons.

It is FAST

You are able to query exactly what you need, nothing more or less. No extra unused data coming in when making api requests.

Multiple Resources

You can make one API call to get multiple resources at once. You are not having to wait for multiple API requests to finish, it is all on one call (again you have the benefit of specifying exactly what you want from each one).

Apollo Client

Apollo Client is the true star in this project. It can make queries and mutations while maintaining a local store, cache queries, and auto update your UI. It has a huge community and will work with any GraphQL API (you can even query REST APIs with it!).

React Native + Apollo Client

Going back to the original problem of letting users create and edit existing notes while having low to no connectivity, I’ll explain my process of how I have Apollo setup and what is required to make it work correctly. After installing Apollo Client, also add apollo-cache-persist and apollo-link-queue. Apollo Cache Persist works with InMemoryCache from the client to immediately restore cache from Apollo upon app load. Apollo Link Queue does what the name implies and will queue up mutations made, and will open and close based off of what you tell it to do. I used NetInfo for checking the networks status to determine when the queue should be open or closed.

Client Setup

I am using a function getApolloClient in my root component to create my Apollo Client upon a successful login in the app. A lot of this setup can be found in the docs in the above links. I created a gist on Github that contains full files with the code used in my below examples. Here are the key points to making the queuing work when creating the client:

// apolloClient.js

export const getApolloClient = async (token, queueLink) => {
  // ...
  
  const link = ApolloLink.from([
    queueLink,
    authLink,
    httpLink,
  ]);

  await persistCache({
    cache,
    storage: AsyncStorage,
  });

  return new ApolloClient({ cache, link });
};

The key to having queueLink work once it is setup in Apollo, is to add a listener that is watching the connection status. Setting this up will ensure that your mutations will be queued up and not lost when there is no connection. Ideally this will be at the root where your Apollo client is initialized.

// App.js
  
const App = () => {
  // ...  
  
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      if (state.isConnected) {
        queueLink.open();
      } else {
        queueLink.close();
      }
    });

    return () => unsubscribe();
  }, []);
  
  // ...
}

Making the Mutations - Optimistic Response

With Apollo V3 come hooks! The useMutation hook is extremely powerful, and configurable. There is a method called optimisticResponse that we will use that will perform exactly what we expect to happen when we create a note in the app. Since the app will allow the users to create notes even when offline, we need to update the local cache so the app immediately reflects the expected changes when the mutation is called. If we do not setup the optimisticResponse, the UI of the app will not update when turning off network and making the mutation. It will show up correctly after connection is regained, but does not make for a good user experience if it doesn’t immediately show up.

You will need to understand how your api is returning data to have it correctly doing local mutations.

Note: below is specific to how this server is setup to handle mutations. This will give you a good idea on how to setup your own to get the response working correctly!

The note data currently looks like this in cache:

data: {
    notes: {
        // nodes is an array of our note objects
        nodes: [{}, {}, {}]
    }
}

Below I will show my entire useMutation hook. After, I will break down each part for more explanation.

// NoteView.js

const NoteView = () => {
// ...

const [
    createNote,
    { error: createError, data: createData, loading: createLoading },
  ] = useMutation(CREATE_NOTE, {
    // The note variable is defined by the server I am connected to.
    // Input your variables here
    variables: {
      note: {
        employeeId: employee.id,
        name: noteName,
        content: noteContent,
      },
    },
    // See breakdown below
    optimisticResponse: {
      __typeName: 'Mutation',
      createNote: {
        __typename: 'CreateNotePayload',
        note: {
          __typeName: 'Note',
          id: getUniqueId(),
          name: noteName,
          content: noteContent,
        },
        errors: [],
      },
    },
    // See breakdown below
    update: (cache, { data }) => {
      try {
        const readData: any = cache.readQuery({
          query: GET_NOTES,
          variables: { employeeId: employee.id },
        });

        let mergedNotes = [];

        if (readData.notes.nodes.length <= 0) {
          mergedNotes = [data.createNote.note];
        } else {
          mergedNotes = [
            data.createNote.note,
            ...readData.notes.nodes,
          ];
        }

        cache.writeQuery({
          query: GET_NOTES,
          variables: { employeeId: employee.id },
          data: {
            ...readData,
            notes: {
              nodes: mergedNotes,
            },
          },
        });
      } catch (e) {
        console.log({ e });
      }
    },
  });
  
// ...
}

Breakdown

My useMutation hook will create a note based off of the variables I am passing in.

variables: {
    note: {
        employeeId: employee.id,
        name: noteName,
        content: noteContent,
    },
},

To learn more about useMutation check out the documentation.

Next part to go over is the optimisticResponse. Here is the quick definition given in the Apollo docs:

Optimistic UI is a pattern that you can use to simulate the results of a mutation and update the UI even before receiving a response from the server. Once the response is received from the server, the optimistic result is thrown away and replaced with the actual result.

optimisticResponse: {
    __typeName: 'Mutation',
   createNote: {
    __typename: 'CreateNotePayload',
    note: {
        __typeName: 'Note',
        id: getUniqueId(),
        name: noteName,
        content: noteContent,
    },
    errors: [],
    },
},

Essentially we are simulating what would happen if we successfully sent a mutation to the server. We want to have this so we can update the local cache to immediately show changes on the UI.

Updating the cache is want we want to do next, so we call the update method on the hook. Wrap this in a try-catch statement to account for any possible errors, and handle them appropriately.

update: (cache, { data }) => {
      try {
        const readData: any = cache.readQuery({
          query: GET_NOTES,
          variables: { employeeId: employee.id },
        });

        let mergedNotes: any[] = [];

        if (readData.notes.nodes.length <= 0) {
          mergedNotes = [data.createNote.note];
        } else {
          mergedNotes = [
            data.createNote.note,
            ...readData.notes.nodes,
          ];
        }

        cache.writeQuery({
          query: GET_NOTES,
          variables: { employeeId: employee.id },
          data: {
            ...readData,
            notes: {
              nodes: mergedNotes,
            },
          },
        });
      } catch (e) {
        console.log({ e });
      }
    },

The update function is replicating what we would do if we made the mutation to the server, and then updating our local cache like we refetched the notes after the mutation is called. The variable data is what we passed along in our optimistic response note object. So first thing we do is make a query to our local cache stored in Apollo. My notes query looks like this:

export const GET_NOTES = gql`
  query GetNotes($employeeId: ID!) {
    notes(employeeId: $employeeId) {
      nodes {
        content
        id
        name
      }
    }
  }
`;

We define an empty array for combined notes, mergedNotes, for adding the new note. We need to check and see if there is any data first and if there is not, we create the new array with that. If notes do exist, we spread the previous values while adding in the new note.

if (readData.notes.nodes.length <= 0) {
   mergedNotes = [data.createNote.note];
} else {
    mergedNotes = [
        data.createNote.note,
        ...readData.notes.nodes,
    ];
}

Note: I’m adding the note first in the list before spreading previous values, because in my case the new notes are added to the beginning of the list. The most recently added or updated show first. You can have it flipped if that is the use case needed for your app.

Next up, we need to save this updated array into our cache so that our UI will correctly reflect the changes immediately.

cache.writeQuery({
    query: GET_NOTES,
    variables: { employeeId: employee.id },
    data: {
       ...readData,
        notes: {
            nodes: mergedNotes,
        },
    },
});

We need to spread the readData while adding in the newly created variable mergedNotes. I have it nested under notes => nodes based off of my query returned data structure.

That’s the last step needed, now your mutations will queue up if the phone lost connection and will re-send once connection is regained!

Link to the full code.

If you want to see this in action, console log queueLink where you created it. Now when you turn off your internet on your computer while doing a mutation, the simulator will log out what is happening in your queue!

Wrap Up

We have gone over the power that GraphQL and Apollo Client can give our React Native apps. There are most certainly different ways to achieve the same result, but this is what worked during my research and development. The more I dive into the topic of offline first approach, the more I realize how important it is to consider during development. We as developers can easily overlook this issue as we develop the apps in sometimes unrealistic environments. Thinking past the scenario of the app example, there are a lot of people who live in areas with not the greatest connections, or people who may commute and be constantly in and out of connection. There are so many different edge cases that keeping these things in mind will continue to help you create better user experiences that are inclusive of different situations.