Updated February 8, 2017

Offline First React Native + Meteor Apps

Originally publish on medium.com.

0

Want to learn more about building offline React Native apps? Check out the comprehensive class on it!

A topic that has come up numerous times in consulting calls, among students in my Learn React Native + Meteor course, and on issues for react-native-meteor is offline support. In short, how can I do make my React Native + Meteor app offline capable?

I’ve thought about this before but never came to a great conclusion — especially for data that is coming in from a publication. The solutions I always came up with before relied on the user having to set up Redux in their app.

Through various conversations I’ve found that many people aren’t using Redux in their React Native + Meteor app, which is perfectly fine. Minimongo does many of the same things as Redux and it’s what Meteor developers are used to.

The Problem

There are many problems associated with building on offline first app — saving data to the client so it works offline, reconciling actions/methods taken when offline, and minimizing any duplicate subscription data.

The Goal

With this post I’m going to try to accomplish saving data, that was brought in via a Meteor subscription, on the client so that it’s there the next time the user starts the app. It’s primary goal is to give a user a useful experience as soon as they open the app without having to wait on a subscription to complete or rely on an internet connection.

Data should stay on the device until a message has been received to remove that document.

The Strategy

I could just publish a package and tell you to use it but I want think this is a solution that will take some time to develop into something truly usable that covers multiple use cases. So in this article we’ll be covering how to implement the logic in your own app so that you can use it, understand it, experiment with it, and provide feedback.

Before diving into the implementation I have to give credit where credit is due and in this case it’s to Julian_Kingman who developed a package, react-native-meteor-redux , which I used as a starting point for much of what I’m going to cover. His solution works well if you’re already using Redux in your app but I wanted to shoot for a more simple implementation.

To accomplish this we’ll listen to three ddp messages — added, changed, and removed. We’ll also be implementing redux behind the scenes so that we can use the excellent redux-persist library to actually handle saving data to disk as well as the ability to automatically “rehydrate” data.

Implementation

The app we’re building will be extremely simple. The Meteor app will simply be the result of running meteor create --full MeteorApp. We’ll then be able to use the web interface created there to modify data. On the react native side I’ve created a very simple app, with the help of react-native-elements that lists the items and then allows us to add new links. To keep this tutorial focused the “new” links will be randomly chosen from an array links.

We’ll also going to display the app’s DDP connection status, which signifies if we’re connected to the Meteor server or not.

You can create the basic app by following these steps.

react-native init RNDemo
npm i --save lodash redux react-native-elements react-native-meteor react-native-vector-icons redux redux-logger redux-persist
react-native link react-native-vector-icons

Then copy and paste the contents of this file to your app entry point. You should now be able to follow along with this tutorial. A full Github link to the demo is available at the end of this post.

1

First thing we want to do is setup our public API for this offline functionality. I’ll be doing my work in react-native-meteor-offline.js and exporting an initializeMeteorOffline function which will be called directly after I call Meteor.connect.

react-native-meteor-offline.js

export const initializeMeteorOffline = (opts = {}) => {
  console.log('Initialize Meteor offline.');
};

index.js

import { initializeMeteorOffline } from './react-native-meteor-offline';

Meteor.connect('ws://localhost:3000/websocket');
initializeMeteorOffline();

That’s all the work we’ve got to do in our existing Meteor app.

Now inside of our initializeMeteorOffline function we want to listen to a few DDP messages that pertain to subscription responses. react-native-meteor makes that easy for us by exposing the DDP connection at Meteor.ddp, which we can then listen for events on.

react-native-meteor-offline.js

import Meteor from 'react-native-meteor';

export const initializeMeteorOffline = (opts = {}) => {
  Meteor.ddp.on('added', (payload) => {
    console.log('Doc added', payload);
  });

  Meteor.ddp.on('changed', (payload) => {
    console.log('Doc changed', payload);
  });

  Meteor.ddp.on('removed', (payload) => {
    console.log('Doc removed', payload);
  });
};

If you have a meteor app running at localhost:3000 you may see a flood of messages in your console at this point. This is the information we’ll use to save data, via redux, to disk so that the user may have a usable offline experience.

2

The first step to persisting this to set up the Redux store. I won’t go into the details of Redux but our store is where we “store” all of our data — tricky naming, huh? We’ll also be leveraging a convenience package, redux-logger, which will help give us insight into what is happening with Redux.

react-native-meteor-offline.js

import Meteor from 'react-native-meteor';
import { createStore, applyMiddleware } from 'redux';
import createLogger from 'redux-logger';

// Reducer
const reducer = (state = {}, action) => {
  return state;
};

export const initializeMeteorOffline = (opts = {}) => {
  const logger = createLogger({ predicate: () => opts.log || false });
  const store = createStore(reducer, applyMiddleware(logger));

  Meteor.ddp.on('added', (payload) => {
    // TODO
  });

  Meteor.ddp.on('changed', (payload) => {
    // TODO
  });

  Meteor.ddp.on('removed', (payload) => {
    // TODO
  });
};

With the store configured we can then start dispatching actions upon any of these three DDP messages. This will allow the reducer to response correctly depending on the type of message, which will then update data on disk.

react-native-meteor-offline.js

// ...

// Actions
const ADDED = 'ddp/added';
const CHANGED = 'ddp/changed';
const REMOVED = 'ddp/removed';

// ...

export const initializeMeteorOffline = (opts = {}) => {
  // ...

  Meteor.ddp.on('added', (payload) => {
    store.dispatch({ type: ADDED, payload });
  });

  Meteor.ddp.on('changed', (payload) => {
    store.dispatch({ type: CHANGED, payload });
  });

  Meteor.ddp.on('removed', (payload) => {
    store.dispatch({ type: REMOVED, payload });
  });
};

3

Now we need to use the reducer to actually update our store when certain actions are dispatched. This will be the bulk of our code and all it’s doing is gradually building up an object with all of our data, modifying any pieces if necessary, and finally removing any data when told to by the server. If you spot any errors in this please let me know.

react-native-meteor-offline.js

// ...
import { REHYDRATE } from 'redux-persist/constants';
import _ from 'lodash';

// ...

// Reducer
const reducer = (state = {}, action) => {
  const { collection, id, fields } = action.payload || {};

  switch (action.type) {
    case ADDED:
      if (!state[collection]) {
        state[collection] = {};
        return {
          ...state,
          [collection]: {
            [id]: fields,
          },
        };
      } else if (!state[collection][id]) {
        return {
          ...state,
          [collection]: {
            ...state[collection],
            [id]: fields,
          },
        };
      } else {
        return {
          ...state,
          [collection]: {
            ...state[collection],
            [id]: { ...fields, ...state[collection][id] },
          },
        };
      }
    case CHANGED:
      return {
        ...state,
        [collection]: {
          ...state[collection],
          [id]: _.merge(state[collection][id], fields),
        },
      };
    case REMOVED:
      if (state[collection] && state[collection][id]) {
        return {
          ...state,
          [collection]: _.omit(state[collection], id),
        };
      }
    case REHYDRATE:
      return action.payload;
    default:
      return state;
  }
};

// ...

We’re then able to check, via redux-logger, that our redux store is being updated correctly.

4

The last piece we need to accomplish is actually persisting the data that we’re writing to Redux to disk, via AsyncStorage, and then to populate our minimongo-cache with that data upon app startup. redux-persist makes this very easy for us to do.

First we have to tell redux-persist what store we want to save to disk and tell it a storage engine (in our case AsyncStorage). We’ll also be leveraging a few other options to improve performance and interoperability. We’ll improve performance by “debouncing” how often we write to disk, which is an expensive operation, meaning that the write-to-disk will happen only every X milliseconds (1000 by default). We’ll also attempt to improve interoperability by setting a unique key to save data in AsyncStorage. That way, if you’re using redux-persist in your app already data won’t be overridden between them.

We then want to tell Redux to automatically rehydrate our store with the information that was saved on disk when it’s first initialized. This will be beneficial because we can read from disk faster than we can from over the network and we ensure that the last dataset that existed when the user last used the app is still on the device.

react-native-meteor-offline.js

// ...
import { persistStore, autoRehydrate } from 'redux-persist';
import { AsyncStorage } from 'react-native';

// ...

// ...

export const initializeMeteorOffline = (opts = {}) => {
  const logger = createLogger({ predicate: () => opts.log || false });
  const store = createStore(reducer, applyMiddleware(logger), autoRehydrate());
  persistStore(store, {
    storage: AsyncStorage,
    keyPrefix: 'react-native-meteor-offline:',
    debounce: opts.debounce || 1000,
  });

  // ...
};

If you look at your console log now you should see a new action dispatched and it should be the first one you see.

5

This is pulling data from the disk and then populating the Redux store from it, all before we’ve connected to the DDP server. But we’re not quite done yet. Though our Redux store has our cached data the actual Meteor app does not. For this we’ll need to insert data to minimongo-cache upon successful rehydration.

react-native-meteor-offline.js

// ...

const onRehydration = (store) => {
  const data = Meteor.getData();
  const db = data && data.db;
  if (db) {
    _.each(store.getState(), (collectionData, collectionName) => {
      if (!db[collectionName]) {
        db.addCollection(collectionName);
      }

      const collectionArr = _.map(collectionData, (doc, _id) => {
        doc._id = _id;
        return doc;
      });

      db[collectionName].upsert(collectionArr);
    });
  }
};

export const initializeMeteorOffline = (opts = {}) => {
  const logger = createLogger({ predicate: () => opts.log || false });
  const store = createStore(reducer, applyMiddleware(logger), autoRehydrate());
  persistStore(
    store,
    {
      storage: AsyncStorage,
      keyPrefix: 'react-native-meteor-offline:',
      debounce: opts.debounce || 1000,
    },
    () => onRehydration(store)
  );

  // ...
};

This goes through all of the data we have in the store and then inserts it into the minimongo-cache, which is what our Meteor app uses. You can then use Meteor.collection('links').find() just like you would normally, even offline.

The full file is available on Github.

Testing

Videos are easier than words at times. I’ve recorded a short video that demonstrates the capabilities of this code.

Example app on Github

React Native School Logo

React Native School

Want to further level up as a React Native developer? Join React Native School! You'll get access to all of our courses and our private Slack community.

Learn More