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
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
Don't forget to install pods:
cd ios && pod install
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,
},
});
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 />;
}
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>
);
}
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
Then install pods:
cd ios && pod install
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 {};
}
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';
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) : [];
},
});
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
};
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
};
Make sure to return data
and addTodo
function:
export default function useOfflineTodosSync() {
// your code...
return {
data,
isFetching,
isError,
addTodo,
};
}
Now back to our Home.tsx
component, lets import this hook:
import useOfflineTodosSync from './hooks/useOfflineTodosSync';
Then, destructure data and our addTodo function:
const {data, addTodo} = useOfflineTodosSync();
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}
/>
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('');
};
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();
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);
}
}
}
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);
};
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));
}
});
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);
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
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
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)