DEV Community

M. Saad Ullah
M. Saad Ullah

Posted on

Building Offline-First Apps using React Native, React Query, and AsyncStorage

As the world is advancing in technology, people have started to expect a lot of things from even a simple mobile app. One of which is being able to run apps offline and also be able to sync data across different devices.

In this article, I will show you how to build an offline-first mobile app using React Native, React Query, and AsyncStorage.

But first, let's understand how syncing data actually works.

How syncing works

In an offline-first app, user data is stored locally in the user's device as well as in the server.

If a user a loses internet connectivity for some reason, and the data changes, we basically check what data has changed and once internet connection is restored, we modify data in the database accordingly.

You might be wondering, if the data is stored locally, it means that it is not good strategy for apps that have large datasets to be developed in an offline-first approach as app will take up a lot of your phone storage, and you'll be right.

Building offline-first apps that deal with huge amounts of data requires strategic storage solutions such as lazy-loading, partially storing some of the data, on-demand syncing, offline-modes for some specific features, and many others are used.

What we'll build

I will be building a simple offline-first Todo list app for demonstration in this article.

The user will be able to add and fetch tasks and the app will run both online and offline.

I highly recommend that you understand the concept and come up with your solutions according to your project needs.

So without further ado, let's start.

Initialize a new project

Start by creating a new react native project by running the following command in your projects directory:

npx react-native@latest init TodoApp
Enter fullscreen mode Exit fullscreen mode

Inside the project folder, create src folder which will contain all our code files.

Install required libraries

For this project, we will need 3 libraries:

React Query  -  A rock-solid React data fetching library that makes fetching, caching, synchronizing and updating server state a breeze. It works with React Native out of the box so you don't need to configure anything. You can learn more about it in the documentation.

AsyncStorage  -  An asynchronous, unencrypted, persistent, key-value storage system for React Native.

Lodash  -  A modern JavaScript utilities library.

To install these libraries run the following command:

yarn add lodash @react-native-async-storage/async-storage @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

Don't forget to install pods:

cd ios && pod install
Enter fullscreen mode Exit fullscreen mode

NOTE: Before continuing with this article, I highly recommend getting a basic understanding of how React Query works.

Building a basic app UI

First, let's create a basic UI of the app.
Create a file Home.tsx and paste the following code:

import React, {useEffect, useState} from 'react';
import {
  Button,
  FlatList,
  SafeAreaView,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from 'react-native';

export default function Home() {
  const [inputText, setInputText] = useState('');
  const onSubmit = () => {
    // add todo
  };

  return (
    <SafeAreaView style={styles.safeArea}>
      <View style={styles.container}>
        <View
          style={{
            flexDirection: 'row',
            alignItems: 'center',
          }}>
          <TextInput
            style={styles.input}
            placeholder="Enter your todo item"
            placeholderTextColor={'#888'}
            value={inputText}
            onChangeText={text => setInputText(text)}
          />
          <TouchableOpacity style={styles.button} onPress={onSubmit}>
            <Text style={styles.buttonText}>Add</Text>
          </TouchableOpacity>
        </View>
        <FlatList
          data={[]}
          renderItem={({item}) => (
            <View style={styles.todoItemCard}>
              <Text style={styles.todoItem}>{item.title}</Text>
            </View>
          )}
          keyExtractor={item => item.title}
        />
      </View>
    </SafeAreaView>
  );
}
const styles = StyleSheet.create({
  safeArea: {
    flex: 1,
    width: '100%',
    backgroundColor: '#000',
  },
  container: {
    flex: 1,
    alignItems: 'center',
    gap: 8,
    padding: 12,
  },
  input: {
    borderWidth: 2,
    borderColor: 'white',
    flex: 1,
    padding: 16,
    borderRadius: 10,
    fontSize: 18,
    color: '#fff',
    marginVertical: 10,
  },
  button: {
    backgroundColor: 'white',
    marginLeft: 10,
    padding: 12,
    borderRadius: 5,
  },
  buttonText: {
    textAlign: 'center',
    fontSize: 20,
    fontWeight: 'bold',
  },
  todoItem: {
    color: '#000',
    fontSize: 20,
  },
  todoItemCard: {
    backgroundColor: 'white',
    width: '100%',
    marginVertical: 8,
    padding: 16,
    borderRadius: 5,
  },
});
Enter fullscreen mode Exit fullscreen mode

This will build a basic UI for our app so let's replace boilerplate react native code inside App.tsx to display our Home screen:

import Home from './src/Home';
export default function App() {
  return <Home />;
}
Enter fullscreen mode Exit fullscreen mode

We also need to initialize and wrap our app with the Provider provided by React Query so we can access it in our app:

import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import Home from './src/Home';

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Home />
    </QueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

And now we can start using React Query in our application!

Implement useOfflineTodosSync hook

I am going to write a reusable custom hook where all the data fetching and syncing logic for our todos will live. Again, feel free to customize according to your project needs.

Start by creating a hooks folder, and inside create useOfflineTodosSync.ts file.

Listen for network changes

Now, the first thing we will set up is a network listener that will run when a change happens to our internet connection.

For that, we need to install @react-native-community/netinfo package. To do so run:

yarn add @react-native-community/netinfo
Enter fullscreen mode Exit fullscreen mode

Then install pods:

cd ios && pod install
Enter fullscreen mode Exit fullscreen mode

We will then import NetInfo object provided by @react-native-community/netinfo.

Then, we need to attach an event listener that will listen for internet connectivity changes and update our application state accordingly.

In the useOfflineTodosSync.ts file, paste the following code:

import {useEffect, useState} from 'react';
import NetInfo from '@react-native-community/netinfo';

export default function useOfflineTodosSync() {
  const [isOnline, setIsOnline] = useState<boolean | null>(true);
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsOnline(state.isConnected);
      if (state.isConnected) {
        // sync our todos
      }
    });
    return () => unsubscribe();
  }, []);
  return {};
}
Enter fullscreen mode Exit fullscreen mode

Creating and fetching todos using React Query

We now need a way to fetch our todos from storage and for that we will use AsyncStorage and useQuery hook provided by React Query:

import {useQuery} from '@tanstack/react-query';
import AsyncStorage from '@react-native-async-storage/async-storage';
Enter fullscreen mode Exit fullscreen mode

Inside useOfflineTodosSync hook:

const localId = 'todos';
const {
    data,
    isFetching,
    isError,
  } = useQuery({
    queryKey: [localId],
    queryFn: async () => {
      const result = await AsyncStorage.getItem(localId);
      return result ? JSON.parse(result) : [];
    },
  });
Enter fullscreen mode Exit fullscreen mode

useQuery takes an object parameter containing queryKey which is a unique key for the query and a function which returns a promise that resolves with data or throws an error. In our case, we are just getting our todos from the storage.

To learn how queries work in react-query, read here.

useQuery provides us many useful stuff but for now I have destructured:

  • data which is a cached data object maintained by React Query and which will contain our todos.
  • isFetching which contains loading state.
  • isError which is a boolean indicating whether an error occurred or not.

If you're using TypeScript, define type definition of todo item:

type Todo = {
  title: string;
  isCompleted: boolean;
  synced: boolean; // flag to check if the item is synced or not
};
Enter fullscreen mode Exit fullscreen mode

And now lets define our addTodo function which we'll use to create new todos. Inside our hook function body, paste the following:

const addTodo = async (newTodo: Todo) => {
  const updatedTodos = [...data, newTodo];
  // we'll write storing and syncing logic later
};
Enter fullscreen mode Exit fullscreen mode

Make sure to return data and addTodo function:

export default function useOfflineTodosSync() {
 // your code...

return {
    data,
    isFetching,
    isError,
    addTodo,
  };
}
Enter fullscreen mode Exit fullscreen mode

Now back to our Home.tsx component, lets import this hook:

import useOfflineTodosSync from './hooks/useOfflineTodosSync';
Enter fullscreen mode Exit fullscreen mode

Then, destructure data and our addTodo function:

const {data, addTodo} = useOfflineTodosSync();
Enter fullscreen mode Exit fullscreen mode

Pass the data to the FlatList:

<FlatList
  data={data} // <-- replace [] with data
  renderItem={({item}) => (
    <View style={styles.todoItemCard}>
      <Text style={styles.todoItem}>{item.title}</Text>
     </View>
  )}
  keyExtractor={item => item.title}
/>
Enter fullscreen mode Exit fullscreen mode

Now we can add new todos by calling addTodo inside onSubmit handler:

const onSubmit = () => {
  // check if input is empty
  if(!inputText) return console.log('input is empty');

  // add our todo
  addTodo({
   title: inputText,
   isCompleted: false,
   synced: false, // initially synced value will be false
  });

  // reset state
  setInputText('');
};
Enter fullscreen mode Exit fullscreen mode

Now once we add a todo, we need to reflect the updated list on our UI. Right now, our new todo item won't appear in the list because we are not saving updatedTodos to our React Query's cached data.

To update our UI once we add a new item, we need to directly update the cache data provided by useQuery hook.

To do this, React Query provides us 2 ways: either using refetch() function or setQueryData provided by the queryClient instance we passed to QueryClientProvider.

You can read more about the differences between them in the documentation but the key difference is:

  • refetch clears the cache and initiates a new query to update the cache with latest data from the server.

  • setQueryData directly updates the cache.

Since we are building offline-first, we will need to directly update the cache and then sync it in the cloud when our internet is restored. Therefore, we will need to use setQueryData instead of refetch as it relies on internet connection.

We can access queryClient instance using the useQueryClient hook inside our custom hook like so:

import {useQuery, useQueryClient} from '@tanstack/react-query';

const queryClient = useQueryClient();
Enter fullscreen mode Exit fullscreen mode

Sync todos

Now we'll create syncTodos function in which we'll loop over our todos and check which todos are not synced.

Then, it will update those todos in our database when we're connected to the internet by sending a POST request to our server and change the synced flag to true for the todos stored in our local storage otherwise it will keep the sync value false so that when the internet connection is restored, we attempt to sync again.

Inside your hook, paste the following function:

const syncTodos = async (todos: Todo[]) => {
    const unsyncedTodos = todos.filter((todo: Todo) => !todo.synced);
    if (unsyncedTodos.length > 0) {
      try {
        if (isOnline) {
          unsyncedTodos.forEach(async (todo: Todo) => {
            await fetch('http://127.0.0.1:8000/todos/create', {
              method: 'POST',
              body: JSON.stringify(todo),
              headers: {
                'Content-Type': 'application/json',
              },
            });
          });
        }

        const updatedTodos = todos.map((todo: any) =>
          todo.synced ? todo : {...todo, synced: isOnline ? true : false},
        );
        await AsyncStorage.setItem(localId, JSON.stringify(updatedTodos));
        queryClient.setQueryData([localId], updatedTodos);
      } catch (error) {
        console.error('Error syncing todos:', error);
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Modify addTodo function to now update the data and then we will call syncTodos function passing it our updated todos list:

const addTodo = async (newTodo: Todo) => {
  const updatedTodos = [...data, newTodo];
  await syncTodos(updatedTodos);
};
Enter fullscreen mode Exit fullscreen mode

And now finally, inside our network listener, we'll add the code to sync our todos when the our connection is restored:

const unsubscribe = NetInfo.addEventListener(state => {
      setIsOnline(state.isConnected);
      if (state.isConnected) {
        AsyncStorage.getItem(localId)
          .then(todos => {
            if (todos) {
              syncTodos(JSON.parse(todos));
            }
          })
          .catch(err => console.log(err));
      }
    });
Enter fullscreen mode Exit fullscreen mode

Now, if you reload the app you'll notice that syncTodos will run a few times. That is because on initial render, NetInfo can run multiple events depending on different states of the network.

To fix this, we can use debouncing method and delay the execution of syncTodos.

Simply import debounce from lodash and pass the syncTodos function code as the first parameter to the debounce function and 500 milliseconds as the second parameter which is the delay after which we call this syncTodos:

import {debounce} from 'lodash';

const syncTodos = debounce(async (todos: Todo[]) => {
    const unsyncedTodos = todos.filter((todo: Todo) => !todo.synced);
    if (unsyncedTodos.length > 0) {
      console.log('unsynced todos found');

      try {
        if (isOnline) {
          unsyncedTodos.forEach(async (todo: Todo) => {
            await fetch('http://127.0.0.1:8000/todos/create', {
              method: 'POST',
              body: JSON.stringify(todo),
              headers: {
                'Content-Type': 'application/json',
              },
            });
          });
        }

        const updatedTodos = todos.map((todo: any) =>
          todo.synced ? todo : {...todo, synced: isOnline ? true : false},
        );
        await AsyncStorage.setItem(localId, JSON.stringify(updatedTodos));
        queryClient.setQueryData([localId], updatedTodos);
      } catch (error) {
        console.error('Error syncing todos:', error);
      }
    }
  }, 500);
Enter fullscreen mode Exit fullscreen mode

And that's it! We now have reusable custom hook we can use anywhere in the app to fetch and create new todos and sync.

Whenever you call addTodo function while connected to the internet or whenever network state changes, sync will occur.

Now for testing purposes, you can clone this Node.js server repo I have created for you to test this app.

After cloning, install dependencies:

yarn
Enter fullscreen mode Exit fullscreen mode

You will also need to replace MongoDB connection string with your own inside the connectDb function.

To set up a Mongo cluster, simply follow this tutorial:

And then, simply run the command to start the server:

yarn dev
Enter fullscreen mode Exit fullscreen mode

You can now test the app!

Conclusion

I hope I was able to show you how you can develop offline-first app. This obviously, is one of the many approaches but I hope you got the concept.

This was my first article on dev.to which hopefully you liked 😊 Let me know in the comments.

Top comments (0)