Creating offline-friendly React Native apps - Part 2: Real-world example

wernancheta

Wern Ancheta

Posted on July 27, 2019

Creating offline-friendly React Native apps - Part 2: Real-world example

Picking up from part one of this series, we will now apply the things we’ve learned into a practical scenario. In this tutorial, we will update an existing chat app so it becomes offline-friendly.

Prerequisites

Basic knowledge of React Native is required. We’ll also be using Redux and Redux Saga, so a bit of familiarity with those is helpful as well.

We will use some of the packages and techniques used in part one of this series. But since we will be updating an existing app anyway, I recommend that you initialize a new React Native project and copy the files from the starter-chat branch instead. Then you can install the packages with yarn install and set them up individually. All the packages for the starter chat can be linked using the react-native link command.

The chat app uses Chatkit, so you’ll also need Chatkit account. If you’re not familiar with Chatkit, be sure to read the quick start.

App overview

The app that we’re going to update is a chat app. It authenticates the user via a Node.js server. Once authenticated, the user will see a list of all the other users who have logged in with the app. From this screen, the user can select the person they want to chat with:

Users screen

They can then begin chatting. The app also accepts image attachments for each message:

Chat screen

Here’s an overview of the things we’re going to implement to make the app more offline friendly:

  • Offline authentication - the app communicates to a server to authenticate the user every time they open the app. We’ll update it so when the user is offline, it will authenticate the user via a passcode.
  • Offline message composition - the app doesn’t allow the user to type a message when they’re offline. We’ll update it so they could compose their message and commit it even if they’re offline. And once they go online, the messages will be sent automatically.
  • Local storage for recent messages - the app doesn’t show any messages when the user is offline. We’ll update it so it locally stores the recent messages with the last person they chatted with.

You can find the source code used in this tutorial on its GitHub repo. The chat app we will update is on the starter-chat branch, while the final output for this tutorial is on the offline-friendly-chat branch.

Setting things up

Before we proceed, we need to install a few packages first:

yarn add react-native-offline redux-persist redux-saga
Enter fullscreen mode Exit fullscreen mode

React Native Offline allows us to easily implement offline capabilities in the app. While Redux Persist allows us to persist the Redux store so its data can be accessed while the user is offline.

Redux Saga allows us to create a watcher for internet connectivity changes. This allows us to implement things like banners to notify the user when they go offline.

Note that if you’re following this tutorial some time in the future, there might be breaking changes in some of the packages. To ensure the app works, it’s recommended that you install the same package versions indicated in the package.json file.

Once the packages are installed, link them:

react-native link
Enter fullscreen mode Exit fullscreen mode

Then add the necessary permissions to the android/app/src/main/AndroidManifest.xml file. The first two are for the React Native Image Picker and the last one is for React Native Offline:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
Enter fullscreen mode Exit fullscreen mode

Next, install the packages required by the server:

cd server
yarn install
Enter fullscreen mode Exit fullscreen mode

Still inside the server directory, update the server.js file with your Chatkit instance locator ID and Chatkit secret. Omit the v1:us1: from the instance locator ID:

const instance_locator_id = "YOUR INSTANCE LOCATOR ID";
const chatkit_secret = "YOUR CHATKIT SECRET";
Enter fullscreen mode Exit fullscreen mode

You can run the server once that’s done:

node server.js
Enter fullscreen mode Exit fullscreen mode

Use ngrok to expose it to the internet. You can find your ngrok auth token on your dashboard:

./ngrok authtoken YOUR_NGROK_AUTH_TOKEN
./ngrok http 3000
Enter fullscreen mode Exit fullscreen mode

Navigate back to the root of the React Native project and update the src/helpers/loginUser.js file with your Chatkit instance locator ID:

const instanceLocatorId = "YOUR INSTANCE LOCATOR ID";
Enter fullscreen mode Exit fullscreen mode

Lastly, update the src/screens/LoginScreen.js file with your Ngrok URL:

const CHAT_SERVER = "https://YOUR_NGROK_URL/users";
Enter fullscreen mode Exit fullscreen mode

At this point, you can now run the app:

react-native run-android
Enter fullscreen mode Exit fullscreen mode

Updating the app

Open the Root.js file and import the following. These will allow you to persist the Redux store and listen for internet connectivity changes:

// Root.js
import { persistStore, persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";
import { PersistGate } from "redux-persist/integration/react";

import createSagaMiddleware from "redux-saga";

import {
  withNetworkConnectivity,
  reducer as network,
  createNetworkMiddleware
} from "react-native-offline";

import ChatReducer from "./src/reducers/ChatReducer";

import { watcherSaga } from "./src/sagas";

const sagaMiddleware = createSagaMiddleware();
const networkMiddleware = createNetworkMiddleware();
Enter fullscreen mode Exit fullscreen mode

Next, add the Redux Persist config. Here we’re blacklisting the network key (this comes from React Native Offline), as well as the chat key which comes from the Chat reducer. Blacklisting allows us to exclude specific data from being persisted locally. This means that it will still use the initial state returned by the reducer. This makes sense for the network key because we expect it to change values when the user goes offline or online. But for chat, we only need to exclude isNetworkBannerVisible. That’s why we’re using nested persist to specify which properties under the blacklisted key actually gets excluded:

const persistConfig = {
  key: "root",
  storage,
  blacklist: ["network", "chat"]
};

const chatPersistConfig = {
  key: "chat",
  storage: storage,
  blacklist: ["isNetworkBannerVisible"] // exclude chat.isNetworkBannerVisible
};

const rootReducer = combineReducers({
  chat: persistReducer(chatPersistConfig, ChatReducer),
  network
});

const persistedReducer = persistReducer(persistConfig, rootReducer);
Enter fullscreen mode Exit fullscreen mode

Next, create the persisted version of the store and run the watcher saga:

const store = createStore(
  persistedReducer,
  applyMiddleware(networkMiddleware, sagaMiddleware)
);
let persistor = persistStore(store);

sagaMiddleware.run(watcherSaga);
Enter fullscreen mode Exit fullscreen mode

Next, wrap the RootStack component with withNetworkConnectivity. This makes the network key available in the store. After that, we can wrap the resulting component with React Navigation’s createAppContainer as usual:

const App = withNetworkConnectivity({
  withRedux: true
})(RootStack);

const AppContainer = createAppContainer(App);
Enter fullscreen mode Exit fullscreen mode

Lastly, wrap the AppContainer with the PersistGate component. This allows us to use the persisted store and delay the rendering of the app’s UI until the persisted state is retrieved. The loading prop accepts the component which you want to display while it’s retrieving the data. But in this case, we’ve passed null because the retrieval happens almost instantly:

class Router extends Component {
  render() {
    return (
      <Provider store={store}>
        <PersistGate loading={null} persistor={persistor}>
          <AppContainer />
        </PersistGate>
      </Provider>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s the code for the watcher saga for listening for network status changes:

// sagas/index.js
import { networkEventsListenerSaga } from "react-native-offline";
import { fork, all } from "redux-saga/effects";

export function* watcherSaga() {
  yield all([
    fork(networkEventsListenerSaga, {
      timeout: 5000, // 5-second timeout for retrieving the network status
      checkConnectionInterval: 1000 // check network status every 1 second
    })
  ]);
}
Enter fullscreen mode Exit fullscreen mode

Login screen

Now we’re ready to update the Login screen. This is where we’ll add a passcode so we can authenticate the user with it when they are offline. This means that we’re basically making an assumption that the last user who logged in is the one that’s logging in. We’ll also be adding the code for displaying a banner when the user is offline.

The first thing we need is to install the React Native Sensitive Info package. This will allow us to locally store the passcode securely:

yarn add react-native-sensitive-info
react-native link react-native-sensitive-info
Enter fullscreen mode Exit fullscreen mode

Next, open the code for the Login screen and import the necessary package and component:

// src/screens/LoginScreen.js
import { connect } from "react-redux";

import SInfo from "react-native-sensitive-info";
import NetworkStatusBanner from "../components/NetworkStatusBanner";

state = {
  passcode: "" // add this to existing state
};
Enter fullscreen mode Exit fullscreen mode

Next, update the render method to show the network status banner. isNetworkBannerVisible decides whether the banner is visible or not. Its value is only true if it meets a certain condition which we’ll take a look at later in the Chat reducer file:

// src/screens/LoginScreen.js
render() {
  const { isConnected, isNetworkBannerVisible } = this.props;
  return (
    <View style={styles.wrapper}>
      <NetworkStatusBanner
        isConnected={isConnected}
        isVisible={isNetworkBannerVisible}
      />
      <View style={styles.container}>
        <View style={styles.main}>
          <View style={styles.fieldContainer}>
            {
              isConnected &&
              <View>
                /* current username field code */
              </View>
            }

            <Text style={styles.label}>Enter your passcode</Text>
            <TextInput
              style={styles.textInput}
              onChangeText={passcode => this.setState({ passcode })}
              maxLength={6}
              secureTextEntry={true}
              value={this.state.passcode}
            />

            {!this.state.enteredChat && (
              /* current login button code */
            )}

            {this.state.enteredChat && (
              /* current loading text */
            )}
          </View>
        </View>
      </View>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, add the code for logging in the user when they’re offline. As you can see from the starter code, we’re leveraging try..catch statements a lot. This allows the app to gracefully handle errors that are brought about with the user being offline. So we can simply add the code for dealing with offline users at the very bottom of the enterChat method. Here, we check whether the passcode entered is the same as the passcode that was previously stored. If it’s the same then we navigate the user to the Users screen:

// src/screens/LoginScreen.js
enterChat = async () => {
  const { user, isConnected } = this.props;
  const { username, passcode } = this.state;

  // add these at the bottom
  if (!isConnected) {
    const stored_passcode = await SInfo.getItem('passcode', {});
    if (stored_passcode == passcode) {
      this.props.navigation.navigate("Users", {
        currentUser: user // very important (contains: id and username)
      });
    } else {
      Alert.alert("Incorrect Passcode", "Please try again.");
    }

    this.setState({
      passcode: "",
      enteredChat: false
    });  
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that the crucial part in the above code is the currentUser nav param. If the user is online, this is a live Chatkit user instance. This allows you to call methods for joining or leaving rooms and sending or receiving messages. But if the user is offline, and you try to call those methods, the app will return an error, which can’t be handled gracefully using try..catch. That’s why we’re using the current user object so the errors can be handled with try..catch.

Next, update mapStateToProps to return the props we need:

// src/screens/LoginScreen.js
const mapStateToProps = ({ network, chat }) => {
  const { isConnected } = network;
  const { user, isNetworkBannerVisible } = chat;
  return {
    isConnected,
    user,
    isNetworkBannerVisible
  };
};

export default connect(
  mapStateToProps,
  null
)(LoginScreen);
Enter fullscreen mode Exit fullscreen mode

Network status banner

Here’s the code for the network status banner. This will display both the offline and online status, but as you’ve seen earlier, we’re only using it to display a banner when the user is offline:

// src/components/NetworkStatusBanner.js
import React from "react";
import { View, Text } from "react-native";

const NetworkStatusBanner = ({ isConnected, isVisible }) => {
  if (!isVisible) return null;

  const boxClass = isConnected ? "success" : "danger";
  const boxText = isConnected ? "online" : "offline";
  return (
    <View style={[styles.alertBox, styles[boxClass]]}>
      <Text style={styles.statusText}>you're {boxText}</Text>
    </View>
  );
};

const styles = {
  alertBox: {
    padding: 5
  },
  success: {
    backgroundColor: "#88c717"
  },
  danger: {
    backgroundColor: "#f96161"
  },
  statusText: {
    fontSize: 14,
    color: "#fff",
    alignSelf: "center"
  }
};

export default NetworkStatusBanner;
Enter fullscreen mode Exit fullscreen mode

Chat reducer

The Chat reducer is where we specify how the store will be updated depending on the action and payload received. Since we’re already implementing all of the actions in the app from here, we might as well listen for when the network status changes. This is another part where React Native Offline comes in handy. It provides action types which we can listen to, so we know exactly when the network status changes. network.isConnected is the current network status, while action.payload is the new network status. Both are boolean values representing whether the user is online (true) or offline (false):

// src/reducers/ChatReducer.js
import { offlineActionTypes, reducer as network } from "react-native-offline";

const INITIAL_STATE = {
  isNetworkBannerVisible: false // add this
  /* previous state initialization */
}

export default (state = INITIAL_STATE, action) => {
  switch (action.type) {
    // add these
    case offlineActionTypes.CONNECTION_CHANGE:

    // only make the network banner visible when the network status changes to offline
      if (network.isConnected != action.payload && !action.payload) {
        return { ...state, isNetworkBannerVisible: true };
      } else {
        return { ...state, isNetworkBannerVisible: false };
      }  

    /* previous code */
  }
}
Enter fullscreen mode Exit fullscreen mode

Users screen

The Users screen is where the list of all the users who have logged in with the app are displayed. We’ll update it so it also displays the network status banner. We’ll also add the code to re-initialize the Chatkit user instance every time the user goes back online.

Start by importing the following:

// src/screens/UsersScreen.js
import NetworkStatusBanner from "../components/NetworkStatusBanner";
import loginUser from '../helpers/loginUser';
Enter fullscreen mode Exit fullscreen mode

Next, add the network status banner:

render() {
  const { isConnected, isNetworkBannerVisible } = this.props;
  return (
    <View style={styles.container}>
      <NetworkStatusBanner
        isConnected={isConnected}
        isVisible={isNetworkBannerVisible}
      />
      { this.renderUsers() }
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the component is updated, we check whether the isConnected props was updated from “offline” to “online”. If it did, we re-initialize the Chatkit user instance and re-subscribe the user to the presence room:

async componentDidUpdate (prevProps, prevState) {
  const { isConnected, navigation } = this.props;
  const currentUser = navigation.getParam('currentUser');

  if (isConnected && prevProps.isConnected != isConnected) {
    this.currentUser = await loginUser(currentUser.id);
    this.subscribeToPresenceRoom();
  }
}
Enter fullscreen mode Exit fullscreen mode

Don’t forget to pass the props:

const mapStateToProps = ({ chat, network }) => {
  const { users, isNetworkBannerVisible } = chat;
  const { isConnected } = network;

  return {
    isConnected,
    isNetworkBannerVisible,
    users
  };
};

/* mapDispatchToProps code */

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(UsersScreen);
Enter fullscreen mode Exit fullscreen mode

Chat screen

Now we’re ready to update the Chat screen. As mentioned in the beginning of the tutorial, we’ll update it so it allows the user to type a message and send it while they’re offline. The messages sent will then be sent automatically when the user goes online. Aside from that, we’ll also locally persist the recent messages from the last person the user chatted with.

Start by importing the things we need:

// src/screens/ChatScreen.js
import NetworkStatusBanner from "../components/NetworkStatusBanner";
import loginUser from '../helpers/loginUser';
Enter fullscreen mode Exit fullscreen mode

Next, update the code for initializing the chat room. Currently, we’re only using it to disconnect the user from Chatkit, get the rooms they can join and subscribe to it if it has the same name as the current room (otherwise, we create it). This is also where we empty the messages if the user is online because subscribing to a room will automatically load the 11 most recent messages sent in the room.

While the user is offline, we allow them to send messages. And when they go online, we need to send them automatically. The only problem is we have to reset the messages array if the user is online. This will also delete all the messages that haven’t actually been sent yet. So to solve this problem, we extract the messages that were sent while the user is offline so we can send them later:

initializeChatRoom = async (came_back_online) => { // add came_back_online param

  const { isConnected, room, messages, setMessages, navigation } = this.props;

  // add this
  if (!came_back_online) {
    this.currentUser = navigation.getParam("currentUser");
  }

  this.roomName = navigation.getParam("roomName");

  // add these
  if (isConnected) {
    // extract the messages sent by the current user while they're offline
    this.unsent_messages = messages.filter((msg) => {
      return msg.not_sent;
    });
    setMessages([]);
  }

  /* existing code */
  try {
    // ...
  } catch (err) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, don’t forget to initialize the value for the unsent_messages:

constructor(props) {
  super(props);
  this.unsent_messages = [];
}
Enter fullscreen mode Exit fullscreen mode

Next, update the render method so it displays the network status banner:

render() {
  const { isConnected, isNetworkBannerVisible, room, navigation, messages } = this.props;
  const roomName = navigation.getParam("roomName");

  return (
    <View style={styles.container}>
      <NetworkStatusBanner
        isConnected={isConnected}
        isVisible={isNetworkBannerVisible}
      />

      {(this.state.is_loading || !this.state.is_initialized) && (
        /* ActivityIndicator code */
      )}

      {this.state.is_initialized && roomName == room.name && (
        /* GiftedChat code */
      )}
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, update the code for subscribing the current user to the chat room. Once the user has subscribed to the room, that’s the time where we want to send the unsent messages:

subscribeToRoom = async roomId => {
  const { setCurrentRoom, navigation } = this.props;
  const roomName = navigation.getParam("roomName");

  /* code for setting current room */

  /* code for subscribing to room */
  try {
    // ...
  } catch (e) {
   // ...
  }

  // add these
  try {
    this.unsent_messages.reverse(); // because messages are ordered from most to least recent
    for(msg of this.unsent_messages){
      await this.sendMessage(msg);
    }
    this.unsent_messages = [];
  } catch (e) {
    console.log("error sending unsent messages", e);
  }
};
Enter fullscreen mode Exit fullscreen mode

Next, update the sendMessage method so it includes the attachment for messages that were sent while the user was offline. If the user is offline, we need to add the attachment property to the message because it doesn’t get sent immediately. This will make the attachment available to the message by the time it actually gets sent:

sendMessage = async (message) => {

  const { room, isConnected, putMessage } = this.props;

  /* start of code for sending message when user is online */

  if (this.attachment) {
    msg.attachment = this.getAttachment();
  }

  // add this right below the line above
  if (message.attachment) {
    msg.attachment = message.attachment;
  }

  /* end of code for sending message when user is online */

  // add these
  if (!isConnected) {
    message.not_sent = true;
    if (this.attachment) {
      message.attachment = this.getAttachment();
      message.image = this.attachment.uri;
    }

    putMessage(message);
    this.attachment = null;

    this.setState({
      is_sending: false
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

When the component is updated, we re-initialize the Chatkit user instance and the chat room. Note that we’re supplying true as an argument to initializeChatRoom to specify that the currentUser passed as a nav param shouldn’t be used as the value for this.currentUser (see initializeChatRoom method):

async componentDidUpdate (prevProps, prevState) {
  const { isConnected, user, navigation } = this.props;
  const currentUser = navigation.getParam('currentUser');

  if (isConnected && prevProps.isConnected != isConnected) {
    this.currentUser = await loginUser(currentUser.id);
    this.initializeChatRoom(true);
  } else if (!isConnected  && prevProps.isConnected != isConnected) {
    this.currentUser = user; // plain user object with id and username
  }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, don’t forget to update mapStateToProps so it returns all the props that we need:

const mapStateToProps = ({ chat, network }) => {
  const { isNetworkBannerVisible, user, room, messages } = chat;
  const { isConnected } = network;
  return {
    isConnected,
    isNetworkBannerVisible,
    user,
    room,
    messages
  };
};
Enter fullscreen mode Exit fullscreen mode

At this point, the app should work even while the user is offline.

Conclusion

That’s it! In this tutorial, you learned how to apply the concepts and tips discussed in part one in a practical scenario. Specifically, you learned how to use the tools provided by React Native Offline and Redux Persist to make an existing chat app offline-friendly.

You can find the source code used in this tutorial on its GitHub repo.

Originally published on the Pusher tutorial hub.

💖 💪 🙅 🚩
wernancheta
Wern Ancheta

Posted on July 27, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related