Build Offline Capable React Native Apps

Project Setup & Overview

In order to dive right into the project I’ve gone ahead and created a simple starter project for you.

Download the starter project.

This project uses Expo but you’re welcome to use the React Native CLI instead. The concepts are exactly the same.

Installation

If you’ve never used Expo before, make sure you have the dependencies installed.

Once you’ve download the project, be sure to install the dependencies. I suggest using Yarn but NPM will work fine as well.

yarn install

Running

  • iOS: yarn run ios
  • Android: yarn run android

Android Users: If you experience issues when testing offline try starting the Expo packager with the yarn start --offline command and then in Expo dev tools set the connection type to “local”.

You can open the Expo dev tools by pressing “d” in the terminal window where the Expo packager is running.

Basics: Catching and Displaying Network Errors

App/screens/CreateItem.js

import React from 'react';
import { ScrollView, View, Alert } from 'react-native';

import { TextField } from '../components/Form';
import { Button } from '../components/Button';
import { geoFetch } from '../util/api';

class CreateItem extends React.Component {
  state = {
    title: null,
    description: null,
    latitude: null,
    longitude: null,
    loading: false,
  };

  onCurrentLocationPress = () => {
    navigator.geolocation.getCurrentPosition(res => {
      if (res && res.coords) {
        this.setState({
          latitude: res.coords.latitude.toString(),
          longitude: res.coords.longitude.toString(),
        });
      }
    });
  };

  onSavePress = () => {
    const { title, description, latitude, longitude } = this.state;
    this.setState({ loading: true }, () => {
      geoFetch(`/`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ title, description, latitude, longitude }),
      })
        .then(() => {
          this.props.navigation.popToTop();
        })
        .catch(error => {
          console.log('create item error', error);
          Alert.alert('Sorry, something went wrong.', error.message);
        })
        .finally(() => {
          this.setState({ loading: false });
        });
    });
  };

  render() {
    return (
      <ScrollView contentContainerStyle={{ paddingVertical: 20 }}>
        <TextField
          label="Title"
          placeholder="I am what I am..."
          value={this.state.title}
          onChangeText={title => this.setState({ title })}
        />
        <TextField
          label="Description"
          placeholder="This is a description..."
          value={this.state.description}
          onChangeText={description => this.setState({ description })}
        />
        <TextField
          label="Latitude"
          placeholder="37.3861"
          keyboardType="decimal-pad"
          value={this.state.latitude}
          onChangeText={latitude => this.setState({ latitude })}
        />
        <TextField
          label="Longitude"
          placeholder="-122.0839"
          keyboardType="decimal-pad"
          value={this.state.longitude}
          onChangeText={longitude => this.setState({ longitude })}
        />
        <View style={{ alignItems: 'center' }}>
          <Button
            text="Use Current Location"
            style={{ marginBottom: 20 }}
            onPress={this.onCurrentLocationPress}
          />
          <Button
            text="Save"
            onPress={this.onSavePress}
            loading={this.state.loading}
          />
        </View>
      </ScrollView>
    );
  }
}

export default CreateItem;

App/screens/Details.js

import React from 'react';
import {
  View,
  StyleSheet,
  SafeAreaView,
  Text,
  ScrollView,
  Dimensions,
  InteractionManager,
  Alert,
} from 'react-native';
import MapView, { Marker } from 'react-native-maps';

import { Button } from '../components/Button';
import { geoFetch } from '../util/api';

const screen = Dimensions.get('window');

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F5F5F5',
  },
  section: {
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    marginVertical: 20,
    padding: 14,
    alignItems: 'center',
  },
  titleText: {
    fontWeight: '600',
    fontSize: 18,
    color: '#4A4A4A',
    textAlign: 'center',
    marginBottom: 10,
  },
  text: {
    fontSize: 16,
    color: '#4A4A4A',
    marginBottom: 20,
  },
  map: {
    width: screen.width,
    height: Math.round(screen.height * 0.25),
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    backgroundColor: '#fff',
  },
});

class Details extends React.Component {
  state = {
    loading: false,
    updatedItem: null,
    showMap: false,
  };

  componentDidMount() {
    InteractionManager.runAfterInteractions(() => {
      this.setState({ showMap: true });
    });
  }

  handleLogPress = _id => {
    this.setState({ loading: true }, () => {
      geoFetch(`/log-find?_id=${_id}`, { method: 'PUT' })
        .then(res => {
          this.setState({ updatedItem: res.result });
        })
        .catch(error => {
          console.log('log press error', error);
          Alert.alert('Sorry, something went wrong.', error.message);
        })
        .finally(() => {
          this.setState({ loading: false });
        });
    });
  };

  render() {
    const item = this.state.updatedItem
      ? this.state.updatedItem
      : this.props.navigation.getParam('item', {});

    return (
      <SafeAreaView style={styles.container}>
        <ScrollView>
          {this.state.showMap ? (
            <MapView
              style={styles.map}
              region={{
                latitude: item.latitude,
                longitude: item.longitude,
                latitudeDelta: 0.0922,
                longitudeDelta: 0.0421,
              }}
              zoomEnabled={false}
              scrollEnabled={false}
            >
              <Marker
                coordinate={{
                  latitude: item.latitude,
                  longitude: item.longitude,
                }}
              />
            </MapView>
          ) : (
            <View style={styles.map} />
          )}
          <View style={styles.section}>
            <Text style={styles.titleText}>{item.title}</Text>
            <Text style={styles.text}>{item.description}</Text>
            <Text style={styles.text}>
              {`Found ${item.foundCount || 0} times.`}
            </Text>
            <Button
              text="Log"
              onPress={() => this.handleLogPress(item._id)}
              loading={this.state.loading}
            />
          </View>
        </ScrollView>
      </SafeAreaView>
    );
  }
}

export default Details;

App/screens/List.js

import React from 'react';
import { ActivityIndicator, Alert } from 'react-native';

import { List, ListItem } from '../components/List';
import { geoFetch } from '../util/api';

class ListScreen extends React.Component {
  state = {
    loading: true,
    list: [],
    refreshing: false,
  };

  componentDidMount() {
    this.getData();
  }

  getData = () =>
    geoFetch('/list')
      .then(response => {
        this.setState({
          loading: false,
          refreshing: false,
          list: response.result,
        });
      })
      .catch(error => {
        console.log('list error', error);
        Alert.alert(
          'Sorry, something went wrong. Please try again',
          error.message,
          [
            {
              text: 'Try Again',
              onPress: this.getData,
            },
          ]
        );
      });

  handleRefresh = () => {
    this.setState({ refreshing: true });
    this.getData();
  };

  render() {
    if (this.state.loading) {
      return <ActivityIndicator size="large" />;
    }

    return (
      <List
        data={this.state.list}
        renderItem={({ item, index }) => (
          <ListItem
            title={item.title}
            isOdd={index % 2}
            onPress={() => this.props.navigation.navigate('Details', { item })}
          />
        )}
        onRefresh={this.handleRefresh}
        refreshing={this.state.refreshing}
      />
    );
  }
}

export default ListScreen;

Detecting User’s Network Status

App/screens/List.js

import React from 'react';
import { ActivityIndicator, Alert } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

import { List, ListItem } from '../components/List';
import { geoFetch } from '../util/api';

class ListScreen extends React.Component {
  state = {
    loading: true,
    list: [],
    refreshing: false,
  };

  componentDidMount() {
    this.getData();
  }

  getData = () =>
    NetInfo.fetch()
      .then(state => {
        if (!state.isConnected) {
          throw new Error('Currently offline.');
        }

        return geoFetch('/list');
      })
      .then(response => {
        this.setState({
          loading: false,
          refreshing: false,
          list: response.result,
        });
      })
      .catch(error => {
        console.log('list error', error);
        Alert.alert(
          'Sorry, something went wrong. Please try again',
          error.message,
          [
            {
              text: 'Try Again',
              onPress: this.getData,
            },
          ]
        );
      });

  handleRefresh = () => {
    this.setState({ refreshing: true });
    this.getData();
  };

  render() {
    if (this.state.loading) {
      return <ActivityIndicator size="large" />;
    }

    return (
      <List
        data={this.state.list}
        renderItem={({ item, index }) => (
          <ListItem
            title={item.title}
            isOdd={index % 2}
            onPress={() => this.props.navigation.navigate('Details', { item })}
          />
        )}
        onRefresh={this.handleRefresh}
        refreshing={this.state.refreshing}
      />
    );
  }
}

export default ListScreen;

Notifying User They’re Offline

App/components/Navigation.js

import React from 'react';
import { TouchableOpacity, Platform, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useNetInfo } from '@react-native-community/netinfo';

const iconPrefix = Platform.OS === 'ios' ? 'ios' : 'md';

const styles = StyleSheet.create({
  btnRight: {
    marginRight: 10,
  },
  btnLeft: {
    marginLeft: 10,
  },
});

export const AddButton = ({ navigation }) => (
  <TouchableOpacity
    onPress={() => navigation.navigate('CreateItem')}
    style={styles.btnRight}
    activeOpacity={0.75}
  >
    <Ionicons name={`${iconPrefix}-add`} size={30} color="#fff" />
  </TouchableOpacity>
);

export const CloseButton = ({ navigation }) => (
  <TouchableOpacity
    onPress={() => navigation.pop()}
    style={styles.btnRight}
    activeOpacity={0.75}
  >
    <Ionicons name={`${iconPrefix}-close`} size={30} color="#fff" />
  </TouchableOpacity>
);

export const OfflineNotification = () => {
  const networkState = useNetInfo();

  if (networkState.isConnected) {
    return null;
  }

  return (
    <TouchableOpacity
      onPress={() => alert("You're currently offline.")}
      style={styles.btnLeft}
      activeOpacity={0.75}
    >
      <Ionicons name={`${iconPrefix}-warning`} size={30} color="#fff" />
    </TouchableOpacity>
  );
};

App/index.js

import React from 'react';
import { StatusBar } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';

import List from './screens/List';
import Details from './screens/Details';
import CreateItem from './screens/CreateItem';

import {
  AddButton,
  CloseButton,
  OfflineNotification,
} from './components/Navigation';

const defaultStackOptions = {
  headerStyle: {
    backgroundColor: '#3A8552',
  },
  headerTintColor: '#fff',
  headerLeft: <OfflineNotification />,
};

const Information = createStackNavigator(
  {
    List: {
      screen: List,
      navigationOptions: ({ navigation }) => ({
        headerTitle: 'Items',
        headerRight: <AddButton navigation={navigation} />,
      }),
    },
    Details: {
      screen: Details,
      navigationOptions: ({ navigation }) => ({
        headerTitle: navigation.getParam('item', {}).title,
      }),
    },
  },
  {
    defaultNavigationOptions: {
      ...defaultStackOptions,
    },
  }
);

const App = createStackNavigator(
  {
    Information,
    CreateItem: {
      screen: createStackNavigator(
        {
          CreateCreate: {
            screen: CreateItem,
            navigationOptions: ({ navigation }) => ({
              headerTitle: 'Create Item',
              headerRight: <CloseButton navigation={navigation} />,
            }),
          },
        },
        {
          defaultNavigationOptions: {
            ...defaultStackOptions,
          },
        }
      ),
    },
  },
  {
    headerMode: 'none',
    mode: 'modal',
  }
);

const AppWithContainer = createAppContainer(App);

export default () => (
  <React.Fragment>
    <StatusBar barStyle="light-content" />
    <AppWithContainer />
  </React.Fragment>
);

Disabling Capabilities When Offline

App/screens/CreateItem.js

import React from 'react';
import { ScrollView, View, Alert, StyleSheet, Text } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { Ionicons } from '@expo/vector-icons';

import { TextField } from '../components/Form';
import { Button } from '../components/Button';
import { geoFetch } from '../util/api';

const styles = StyleSheet.create({
  offlineContainer: {
    alignItems: 'center',
    marginTop: 40,
  },
  offlineText: {
    color: 'rgba(0, 0, 0, 0.5)',
    fontSize: 18,
    textAlign: 'center',
  },
});

class CreateItem extends React.Component {
  state = {
    title: null,
    description: null,
    latitude: null,
    longitude: null,
    loading: false,
    offline: false,
  };

  componentDidMount() {
    this.netListenerUnsubscribe = NetInfo.addEventListener(networkState => {
      this.setState({
        offline: !networkState.isConnected,
      });
    });
  }

  componentWillUnmount() {
    if (this.netListenerUnsubscribe) {
      this.netListenerUnsubscribe();
    }
  }

  onCurrentLocationPress = () => {
    navigator.geolocation.getCurrentPosition(res => {
      if (res && res.coords) {
        this.setState({
          latitude: res.coords.latitude.toString(),
          longitude: res.coords.longitude.toString(),
        });
      }
    });
  };

  onSavePress = () => {
    const { title, description, latitude, longitude } = this.state;
    this.setState({ loading: true }, () => {
      geoFetch(`/`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ title, description, latitude, longitude }),
      })
        .then(() => {
          this.props.navigation.popToTop();
        })
        .catch(error => {
          console.log('create item error', error);
          Alert.alert('Sorry, something went wrong.', error.message);
        })
        .finally(() => {
          this.setState({ loading: false });
        });
    });
  };

  render() {
    if (this.state.offline) {
      return (
        <View style={styles.offlineContainer}>
          <Ionicons name="ios-warning" size={90} color="rgba(0, 0, 0, 0.5)" />
          <Text style={styles.offlineText}>
            Sorry, you can't create new items when offline.
          </Text>
        </View>
      );
    }
    return (
      <ScrollView contentContainerStyle={{ paddingVertical: 20 }}>
        <TextField
          label="Title"
          placeholder="I am what I am..."
          value={this.state.title}
          onChangeText={title => this.setState({ title })}
        />
        <TextField
          label="Description"
          placeholder="This is a description..."
          value={this.state.description}
          onChangeText={description => this.setState({ description })}
        />
        <TextField
          label="Latitude"
          placeholder="37.3861"
          keyboardType="decimal-pad"
          value={this.state.latitude}
          onChangeText={latitude => this.setState({ latitude })}
        />
        <TextField
          label="Longitude"
          placeholder="-122.0839"
          keyboardType="decimal-pad"
          value={this.state.longitude}
          onChangeText={longitude => this.setState({ longitude })}
        />
        <View style={{ alignItems: 'center' }}>
          <Button
            text="Use Current Location"
            style={{ marginBottom: 20 }}
            onPress={this.onCurrentLocationPress}
          />
          <Button
            text="Save"
            onPress={this.onSavePress}
            loading={this.state.loading}
          />
        </View>
      </ScrollView>
    );
  }
}
export default CreateItem;

App/screens/Details.js

import React from 'react';
import {
  View,
  StyleSheet,
  SafeAreaView,
  Text,
  ScrollView,
  Dimensions,
  InteractionManager,
  Alert,
} from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import NetInfo from '@react-native-community/netinfo';

import { Button } from '../components/Button';
import { geoFetch } from '../util/api';

const screen = Dimensions.get('window');

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F5F5F5',
  },
  section: {
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    marginVertical: 20,
    padding: 14,
    alignItems: 'center',
  },
  titleText: {
    fontWeight: '600',
    fontSize: 18,
    color: '#4A4A4A',
    textAlign: 'center',
    marginBottom: 10,
  },
  text: {
    fontSize: 16,
    color: '#4A4A4A',
    marginBottom: 20,
  },
  map: {
    width: screen.width,
    height: Math.round(screen.height * 0.25),
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    backgroundColor: '#fff',
  },
});

class Details extends React.Component {
  state = {
    loading: false,
    updatedItem: null,
    showMap: false,
    offline: false,
  };

  componentDidMount() {
    InteractionManager.runAfterInteractions(() => {
      this.setState({ showMap: true });
    });

    this.netListenerUnsubscribe = NetInfo.addEventListener(networkState => {
      this.setState({
        offline: !networkState.isConnected,
      });
    });
  }

  componentWillUnmount() {
    if (this.netListenerUnsubscribe) {
      this.netListenerUnsubscribe();
    }
  }

  handleLogPress = _id => {
    this.setState({ loading: true }, () => {
      geoFetch(`/log-find?_id=${_id}`, { method: 'PUT' })
        .then(res => {
          this.setState({ updatedItem: res.result });
        })
        .catch(error => {
          console.log('log press error', error);
          Alert.alert('Sorry, something went wrong.', error.message);
        })
        .finally(() => {
          this.setState({ loading: false });
        });
    });
  };

  render() {
    const item = this.state.updatedItem
      ? this.state.updatedItem
      : this.props.navigation.getParam('item', {});

    return (
      <SafeAreaView style={styles.container}>
        <ScrollView>
          {this.state.showMap ? (
            <MapView
              style={styles.map}
              region={{
                latitude: item.latitude,
                longitude: item.longitude,
                latitudeDelta: 0.0922,
                longitudeDelta: 0.0421,
              }}
              zoomEnabled={false}
              scrollEnabled={false}
            >
              <Marker
                coordinate={{
                  latitude: item.latitude,
                  longitude: item.longitude,
                }}
              />
            </MapView>
          ) : (
            <View style={styles.map} />
          )}
          <View style={styles.section}>
            <Text style={styles.titleText}>{item.title}</Text>
            <Text style={styles.text}>{item.description}</Text>
            <Text style={styles.text}>
              {`Found ${item.foundCount || 0} times.`}
            </Text>
            <Button
              text="Log"
              onPress={() => this.handleLogPress(item._id)}
              loading={this.state.loading || this.state.offline}
            />
          </View>
        </ScrollView>
      </SafeAreaView>
    );
  }
}

export default Details;

Caching Data from Successful Requests

If you’re using the React Native CLI ensure that you import AsyncStorage correctly.

App/util/api.js

import { AsyncStorage } from 'react-native';

const BASE_URL = 'https://rns-offline-class.glitch.me';

export const geoFetch = async (path, options = {}) => {
  const url = `${BASE_URL}/api${path}`;
  const cacheKey = `CACHED_DATA::${url}`;

  try {
    const res = await fetch(url, options);

    if (!res.ok) {
      throw new Error('Something went wrong... please try again.');
    }

    const data = res.json();

    await AsyncStorage.setItem(
      cacheKey,
      JSON.stringify({
        data,
      })
    );
    const keys = await AsyncStorage.getAllKeys();
    console.log('keys', keys);

    return data;
  } catch (error) {
    console.log('geoFetch error', error);
    return Promise.reject(error);
  }
};

Using Cached Data When Offline

App/screens/List.js

import React from 'react';
import { ActivityIndicator, Alert } from 'react-native';

import { List, ListItem } from '../components/List';
import { geoFetch } from '../util/api';

class ListScreen extends React.Component {
  state = {
    loading: true,
    list: [],
    refreshing: false,
  };

  componentDidMount() {
    this.getData();
  }

  getData = () =>
    geoFetch('/list')
      .then(response => {
        this.setState({
          loading: false,
          refreshing: false,
          list: response.result,
        });
      })
      .catch(error => {
        console.log('list error', error);
        Alert.alert(
          'Sorry, something went wrong. Please try again',
          error.message,
          [
            {
              text: 'Try Again',
              onPress: this.getData,
            },
          ]
        );
      });

  handleRefresh = () => {
    this.setState({ refreshing: true });
    this.getData();
  };

  render() {
    if (this.state.loading) {
      return <ActivityIndicator size="large" />;
    }

    return (
      <List
        data={this.state.list}
        renderItem={({ item, index }) => (
          <ListItem
            title={item.title}
            isOdd={index % 2}
            onPress={() => this.props.navigation.navigate('Details', { item })}
          />
        )}
        onRefresh={this.handleRefresh}
        refreshing={this.state.refreshing}
      />
    );
  }
}

export default ListScreen;

App/util/api.js

import { AsyncStorage } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

const BASE_URL = 'https://rns-offline-class.glitch.me';

export const geoFetch = async (path, options = {}) => {
  const url = `${BASE_URL}/api${path}`;
  const cacheKey = `CACHED_DATA::${url}`;

  try {
    const networkState = await NetInfo.fetch();

    if (!networkState.isConnected) {
      const _cachedData = await AsyncStorage.getItem(cacheKey);
      if (!_cachedData) {
        throw new Error(
          "You're currently offline and no local data was found."
        );
      }

      console.log('cachedData', _cachedData);
      const cachedData = JSON.parse(_cachedData);
      return cachedData.data;
    }

    const res = await fetch(url, options);

    if (!res.ok) {
      throw new Error('Something went wrong... please try again.');
    }

    const data = await res.json();

    await AsyncStorage.setItem(
      cacheKey,
      JSON.stringify({
        data,
      })
    );
    const keys = await AsyncStorage.getAllKeys();
    console.log('keys', keys);

    return data;
  } catch (error) {
    console.log('geoFetch error', error);
    return Promise.reject(error);
  }
};

Allow and Store Actions Taken While Offline

App/screens/Details.js

import React from 'react';
import {
  View,
  StyleSheet,
  SafeAreaView,
  Text,
  ScrollView,
  Dimensions,
  InteractionManager,
  Alert,
} from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import NetInfo from '@react-native-community/netinfo';

import { Button } from '../components/Button';
import { geoFetch } from '../util/api';

const screen = Dimensions.get('window');

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F5F5F5',
  },
  section: {
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    marginVertical: 20,
    padding: 14,
    alignItems: 'center',
  },
  titleText: {
    fontWeight: '600',
    fontSize: 18,
    color: '#4A4A4A',
    textAlign: 'center',
    marginBottom: 10,
  },
  text: {
    fontSize: 16,
    color: '#4A4A4A',
    marginBottom: 20,
  },
  map: {
    width: screen.width,
    height: Math.round(screen.height * 0.25),
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    backgroundColor: '#fff',
  },
});

class Details extends React.Component {
  state = {
    loading: false,
    updatedItem: null,
    showMap: false,
  };

  componentDidMount() {
    InteractionManager.runAfterInteractions(() => {
      this.setState({ showMap: true });
    });
  }

  handleLogPress = _id => {
    this.setState({ loading: true }, () => {
      geoFetch(`/log-find?_id=${_id}`, { method: 'PUT' })
        .then(res => {
          this.setState({ updatedItem: res.result });
        })
        .catch(error => {
          console.log('log press error', error);
          Alert.alert('Sorry, something went wrong.', error.message);
        })
        .finally(() => {
          this.setState({ loading: false });
        });
    });
  };

  render() {
    const item = this.state.updatedItem
      ? this.state.updatedItem
      : this.props.navigation.getParam('item', {});

    return (
      <SafeAreaView style={styles.container}>
        <ScrollView>
          {this.state.showMap ? (
            <MapView
              style={styles.map}
              region={{
                latitude: item.latitude,
                longitude: item.longitude,
                latitudeDelta: 0.0922,
                longitudeDelta: 0.0421,
              }}
              zoomEnabled={false}
              scrollEnabled={false}
            >
              <Marker
                coordinate={{
                  latitude: item.latitude,
                  longitude: item.longitude,
                }}
              />
            </MapView>
          ) : (
            <View style={styles.map} />
          )}
          <View style={styles.section}>
            <Text style={styles.titleText}>{item.title}</Text>
            <Text style={styles.text}>{item.description}</Text>
            <Text style={styles.text}>
              {`Found ${item.foundCount || 0} times.`}
            </Text>
            <Button
              text="Log"
              onPress={() => this.handleLogPress(item._id)}
              loading={this.state.loading}
            />
          </View>
        </ScrollView>
      </SafeAreaView>
    );
  }
}

export default Details;

App/util/api.js

import { AsyncStorage } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

const BASE_URL = 'https://rns-offline-class.glitch.me';

export const geoFetch = async (path, options = {}) => {
  const url = `${BASE_URL}/api${path}`;
  const cacheKey = `CACHED_DATA::${url}`;
  const actionQueueKey = 'CACHED_DATA::ACTION_QUEUE';

  try {
    const networkState = await NetInfo.fetch();

    if (!networkState.isConnected) {
      if (!options.method || options.method.toLowerCase() === 'get') {
        const _cachedData = await AsyncStorage.getItem(cacheKey);
        if (!_cachedData) {
          throw new Error(
            "You're currently offline and no local data was found."
          );
        }

        // console.log("cachedData", _cachedData);
        const cachedData = JSON.parse(_cachedData);
        return cachedData.data;
      }

      if (
        options.method.toLowerCase() === 'put' ||
        options.method.toLowerCase() === 'post'
      ) {
        const _queueActions = await AsyncStorage.getItem(actionQueueKey);
        const queuedActions = _queueActions ? JSON.parse(_queueActions) : [];

        console.log('initial queuedActions', queuedActions);

        queuedActions.push({
          path,
          options,
        });

        await AsyncStorage.setItem(
          actionQueueKey,
          JSON.stringify(queuedActions)
        );

        const _queuedActions2 = await AsyncStorage.getItem(actionQueueKey);
        console.log('after queued actions', _queuedActions2);
      }
    }

    const res = await fetch(url, options);

    if (!res.ok) {
      throw new Error('Something went wrong... please try again.');
    }

    const data = await res.json();

    await AsyncStorage.setItem(
      cacheKey,
      JSON.stringify({
        data,
      })
    );
    const keys = await AsyncStorage.getAllKeys();
    console.log('keys', keys);

    return data;
  } catch (error) {
    console.log('geoFetch error', error);
    return Promise.reject(error);
  }
};

Eliminate Duplicate Offline Requests

App/util/api.js

import { AsyncStorage } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

const BASE_URL = 'https://rns-offline-class.glitch.me';

export const geoFetch = async (path, options = {}) => {
  const url = `${BASE_URL}/api${path}`;
  const cacheKey = `CACHED_DATA::${url}`;
  const actionQueueKey = 'CACHED_DATA::ACTION_QUEUE';

  try {
    const networkState = await NetInfo.fetch();

    if (!networkState.isConnected) {
      if (!options.method || options.method.toLowerCase() === 'get') {
        const _cachedData = await AsyncStorage.getItem(cacheKey);
        if (!_cachedData) {
          throw new Error(
            "You're currently offline and no local data was found."
          );
        }

        // console.log("cachedData", _cachedData);
        const cachedData = JSON.parse(_cachedData);
        return cachedData.data;
      }

      if (
        options.method.toLowerCase() === 'put' ||
        options.method.toLowerCase() === 'post'
      ) {
        const _queueActions = await AsyncStorage.getItem(actionQueueKey);
        let queuedActions = _queueActions ? JSON.parse(_queueActions) : [];

        console.log('initial queuedActions', queuedActions);

        queuedActions.push({
          path,
          options,
        });

        const data = {};
        queuedActions.forEach(action => {
          data[action.path] = action.options;
        });
        queuedActions = Object.keys(data).map(key => {
          return {
            path: key,
            options: data[key],
          };
        });

        await AsyncStorage.setItem(
          actionQueueKey,
          JSON.stringify(queuedActions)
        );

        const _queuedActions2 = await AsyncStorage.getItem(actionQueueKey);
        console.log('after queued actions', _queuedActions2);
      }
    }

    const res = await fetch(url, options);

    if (!res.ok) {
      throw new Error('Something went wrong... please try again.');
    }

    const data = await res.json();

    await AsyncStorage.setItem(
      cacheKey,
      JSON.stringify({
        data,
      })
    );
    const keys = await AsyncStorage.getAllKeys();
    console.log('keys', keys);

    return data;
  } catch (error) {
    console.log('geoFetch error', error);
    return Promise.reject(error);
  }
};

Basic Optimistic Updates

App/screens/Details.js

import React from 'react';
import {
  View,
  StyleSheet,
  SafeAreaView,
  Text,
  ScrollView,
  Dimensions,
  InteractionManager,
  Alert,
} from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import NetInfo from '@react-native-community/netinfo';

import { Button } from '../components/Button';
import { geoFetch } from '../util/api';

const screen = Dimensions.get('window');

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F5F5F5',
  },
  section: {
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    marginVertical: 20,
    padding: 14,
    alignItems: 'center',
  },
  titleText: {
    fontWeight: '600',
    fontSize: 18,
    color: '#4A4A4A',
    textAlign: 'center',
    marginBottom: 10,
  },
  text: {
    fontSize: 16,
    color: '#4A4A4A',
    marginBottom: 20,
  },
  map: {
    width: screen.width,
    height: Math.round(screen.height * 0.25),
    borderTopWidth: 1,
    borderTopColor: '#E4E4E4',
    borderBottomWidth: 1,
    borderBottomColor: '#E4E4E4',
    backgroundColor: '#fff',
  },
});

class Details extends React.Component {
  state = {
    loading: false,
    updatedItem: null,
    showMap: false,
  };

  componentDidMount() {
    InteractionManager.runAfterInteractions(() => {
      this.setState({ showMap: true });
    });
  }

  handleLogPress = _id => {
    const item = this.props.navigation.getParam('item', {});
    const optimisticResponse = {
      result: {
        ...item,
        foundCount: item.foundCount ? item.foundCount + 1 : 1,
      },
    };

    this.setState({ loading: true }, () => {
      geoFetch(`/log-find?_id=${_id}`, { method: 'PUT' }, optimisticResponse)
        .then(res => {
          console.log('res', res);
          this.setState({ updatedItem: res.result });
        })
        .catch(error => {
          console.log('log press error', error);
          Alert.alert('Sorry, something went wrong.', error.message);
        })
        .finally(() => {
          this.setState({ loading: false });
        });
    });
  };

  render() {
    const item = this.state.updatedItem
      ? this.state.updatedItem
      : this.props.navigation.getParam('item', {});

    return (
      <SafeAreaView style={styles.container}>
        <ScrollView>
          {this.state.showMap ? (
            <MapView
              style={styles.map}
              region={{
                latitude: item.latitude,
                longitude: item.longitude,
                latitudeDelta: 0.0922,
                longitudeDelta: 0.0421,
              }}
              zoomEnabled={false}
              scrollEnabled={false}
            >
              <Marker
                coordinate={{
                  latitude: item.latitude,
                  longitude: item.longitude,
                }}
              />
            </MapView>
          ) : (
            <View style={styles.map} />
          )}
          <View style={styles.section}>
            <Text style={styles.titleText}>{item.title}</Text>
            <Text style={styles.text}>{item.description}</Text>
            <Text style={styles.text}>
              {`Found ${item.foundCount || 0} times.`}
            </Text>
            <Button
              text="Log"
              onPress={() => this.handleLogPress(item._id)}
              loading={this.state.loading}
            />
          </View>
        </ScrollView>
      </SafeAreaView>
    );
  }
}

export default Details;

App/util/api.js

import { AsyncStorage } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

const BASE_URL = 'https://rns-offline-class.glitch.me';

export const geoFetch = async (path, options = {}, optimisticResponse = {}) => {
  const url = `${BASE_URL}/api${path}`;
  const cacheKey = `CACHED_DATA::${url}`;
  const actionQueueKey = 'CACHED_DATA::ACTION_QUEUE';

  try {
    const networkState = await NetInfo.fetch();

    if (!networkState.isConnected) {
      if (!options.method || options.method.toLowerCase() === 'get') {
        const _cachedData = await AsyncStorage.getItem(cacheKey);
        if (!_cachedData) {
          throw new Error(
            "You're currently offline and no local data was found."
          );
        }

        // console.log("cachedData", _cachedData);
        const cachedData = JSON.parse(_cachedData);
        return cachedData.data;
      }

      if (
        options.method.toLowerCase() === 'put' ||
        options.method.toLowerCase() === 'post'
      ) {
        const _queueActions = await AsyncStorage.getItem(actionQueueKey);
        let queuedActions = _queueActions ? JSON.parse(_queueActions) : [];

        console.log('initial queuedActions', queuedActions);

        queuedActions.push({
          path,
          options,
        });

        const data = {};
        queuedActions.forEach(action => {
          data[action.path] = action.options;
        });
        queuedActions = Object.keys(data).map(key => {
          return {
            path: key,
            options: data[key],
          };
        });

        await AsyncStorage.setItem(
          actionQueueKey,
          JSON.stringify(queuedActions)
        );

        const _queuedActions2 = await AsyncStorage.getItem(actionQueueKey);
        console.log('after queued actions', _queuedActions2);

        return optimisticResponse;
      }
    }

    const res = await fetch(url, options);

    if (!res.ok) {
      throw new Error('Something went wrong... please try again.');
    }

    const data = await res.json();

    await AsyncStorage.setItem(
      cacheKey,
      JSON.stringify({
        data,
      })
    );
    const keys = await AsyncStorage.getAllKeys();
    console.log('keys', keys);

    return data;
  } catch (error) {
    console.log('geoFetch error', error);
    return Promise.reject(error);
  }
};

Reconciling Offline Actions When the User is Back Online

App/index.js

import React from 'react';
import { StatusBar } from 'react-native';
import { createAppContainer } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';

import List from './screens/List';
import Details from './screens/Details';
import CreateItem from './screens/CreateItem';

import {
  AddButton,
  CloseButton,
  OfflineNotification,
} from './components/Navigation';
import { reconcileActions } from './util/api';

const defaultStackOptions = {
  headerStyle: {
    backgroundColor: '#3A8552',
  },
  headerTintColor: '#fff',
  headerLeft: <OfflineNotification />,
};

const Information = createStackNavigator(
  {
    List: {
      screen: List,
      navigationOptions: ({ navigation }) => ({
        headerTitle: 'Items',
        headerRight: <AddButton navigation={navigation} />,
      }),
    },
    Details: {
      screen: Details,
      navigationOptions: ({ navigation }) => ({
        headerTitle: navigation.getParam('item', {}).title,
      }),
    },
  },
  {
    defaultNavigationOptions: {
      ...defaultStackOptions,
    },
  }
);

const App = createStackNavigator(
  {
    Information,
    CreateItem: {
      screen: createStackNavigator(
        {
          CreateCreate: {
            screen: CreateItem,
            navigationOptions: ({ navigation }) => ({
              headerTitle: 'Create Item',
              headerRight: <CloseButton navigation={navigation} />,
            }),
          },
        },
        {
          defaultNavigationOptions: {
            ...defaultStackOptions,
          },
        }
      ),
    },
  },
  {
    headerMode: 'none',
    mode: 'modal',
  }
);

const AppWithContainer = createAppContainer(App);

export default class RootApp extends React.Component {
  state = {
    reconciling: true,
  };

  componentDidMount() {
    reconcileActions()
      .then(reconciled => {
        console.log(`Have offline actions been reconciled? ${reconciled}`);
      })
      .finally(() => {
        this.setState({ reconciling: false });
      });
  }

  render() {
    if (this.state.reconciling) {
      return null;
    }

    return (
      <React.Fragment>
        <StatusBar barStyle="light-content" />
        <AppWithContainer />
      </React.Fragment>
    );
  }
}

App/util/api.js

import { AsyncStorage } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

const BASE_URL = 'https://rns-offline-class.glitch.me';
const actionQueueKey = 'CACHED_DATA::ACTION_QUEUE';

export const geoFetch = async (path, options = {}, optimisticResponse = {}) => {
  const url = `${BASE_URL}/api${path}`;
  const cacheKey = `CACHED_DATA::${url}`;

  try {
    const networkState = await NetInfo.fetch();

    if (!networkState.isConnected) {
      if (!options.method || options.method.toLowerCase() === 'get') {
        const _cachedData = await AsyncStorage.getItem(cacheKey);
        if (!_cachedData) {
          throw new Error(
            "You're currently offline and no local data was found."
          );
        }

        // console.log("cachedData", _cachedData);
        const cachedData = JSON.parse(_cachedData);
        return cachedData.data;
      }

      if (
        options.method.toLowerCase() === 'put' ||
        options.method.toLowerCase() === 'post'
      ) {
        const _queueActions = await AsyncStorage.getItem(actionQueueKey);
        let queuedActions = _queueActions ? JSON.parse(_queueActions) : [];

        console.log('initial queuedActions', queuedActions);

        queuedActions.push({
          path,
          options,
        });

        const data = {};
        queuedActions.forEach(action => {
          data[action.path] = action.options;
        });
        queuedActions = Object.keys(data).map(key => {
          return {
            path: key,
            options: data[key],
          };
        });

        await AsyncStorage.setItem(
          actionQueueKey,
          JSON.stringify(queuedActions)
        );

        const _queuedActions2 = await AsyncStorage.getItem(actionQueueKey);
        console.log('after queued actions', _queuedActions2);

        return optimisticResponse;
      }
    }

    const res = await fetch(url, options);

    if (!res.ok) {
      throw new Error('Something went wrong... please try again.');
    }

    const data = await res.json();

    await AsyncStorage.setItem(
      cacheKey,
      JSON.stringify({
        data,
      })
    );
    const keys = await AsyncStorage.getAllKeys();
    console.log('keys', keys);

    return data;
  } catch (error) {
    console.log('geoFetch error', error);
    return Promise.reject(error);
  }
};

const runRequests = async actions => {
  const succeeded = [];
  const failed = [];

  for (let index = 0; index < actions.length; index += 1) {
    const req = actions[index];
    try {
      const response = await geoFetch(req.path, req.options);
      succeeded.push(response);
    } catch (error) {
      failed.push(req);
    }
  }

  return {
    succeeded,
    failed,
  };
};

export const reconcileActions = async () => {
  const networkState = await NetInfo.fetch();

  if (!networkState.isConnected) {
    return false;
  }

  try {
    const _queueActions = await AsyncStorage.getItem(actionQueueKey);
    const queuedActions = _queueActions ? JSON.parse(_queueActions) : [];
    const { failed } = await runRequests(queuedActions);

    await AsyncStorage.setItem(actionQueueKey, JSON.stringify(failed));

    const _queueActions2 = await AsyncStorage.getItem(actionQueueKey);
    console.log('que', _queueActions2);
    return true;
  } catch (error) {
    console.log('reconcileActions error', error);
    return false;
  }
};

Wrapping Up

Final Code

You can download the final code here.

Review

I aim to create the best React Native instructional material there is. It would be extremely helpful if you could take a minute to leave a review!

Additional Challenges

Keep going! Learning really happens when you start writing code on your own. Here are a few challenges to add to the app we’ve been working on.

  1. Notify the user when cached requests have succeed/failed.
  2. Notify the user if they have pending cached requests that need to be reconciled (local push notification, perhaps?)
  3. Enable offline item creation