How to build an offline-first app using Expo & Legend State

Development9 minutes read

Callum Hemsley

Callum Hemsley

Guest Author

Learn how to build and assess the effectiveness of an offline first application using Legend State and Expo.

How to build an offline-first app using Expo & Legend State

This is a guest post from Callum Hemsley who is a tech lead at Theodo, and has worked on several projects with startups and established enterprises to create cross-platform mobile apps in React Native. He is also an organizer of React Native London.

•••

Embracing a local-first approach in app development has always resonated with me, especially to counter the frustrations of connectivity-dependent applications which are prevalent today. The goal is simple: create apps that are immediately responsive and liberated from the constant need for internet connection. But execution is challenging, particularly for complex, multi-user applications.

Expo is well suited for app development using the local-first approach, because almost all platforms are supported. With additions such as the expo-sqlite integration with CR-SQLite, it’s clear that Expo is keen to embrace this new alternative for developing applications.

At React Native London, Jay Meistrich introduced Legend State, a high-performance, developer-friendly state management library that is uniquely appropriate for offline-first persistence. I was intrigued and knew I had to give it a try.

In this article, I'll walk you through my journey of building an offline-first 'expenses tracker' for my household with Legend State in an Expo app. The app will allow users to add expenses from any of their devices, similar to the app SplitWise. Data will sync immediately to local storage, then upload to the cloud once a device is online, ensuring seamless updates across all users' devices.

Along the way, we'll explore Legend State's syncing capabilities and examine the trade-offs involved.

Here’s a sneak peak of what the final app looks like:

Local first vs offline first apps

In offline-first apps, changes made during offline sessions are preserved and later synchronized when connectivity is restored. This approach ensures that any actions taken while disconnected are not lost.

In contrast, local-first technology adopts a fundamentally different approach: the primary source of truth for data resides locally on the user's device. Synchronization with servers is secondary to the local data. This means that the app can continue to work indefinitely, even if the servers go offline entirely.

Legend State supports offline-first, allowing users to continue working on apps without connectivity. However, as Ink & Drop describes, we don’t get the benefit of “owning your data, in spite of the cloud” that we would get through local-first apps.

Creating a basic stateful expenses app with Legend State & Expo

Don’t feel like reading? Here’s a link to the code.

It’s incredible how far the React Native community has progressed in the last few years. Expo now makes it easier than ever to bootstrap and start a new project using React Native.

I swiftly moved to a functional proof-of-concept with Legend State in under an hour by following the core concept examples.

After installing @legendapp/state, I setup a simple default state that could be observed:

Code
import { observable } from "@legendapp/state";
const state = observable({
expenses: [
{ id: "1", title: "Groceries", amount: 50.0 },
{ id: "2", title: "Electric Bill", amount: 75.0 },
],
});

Then I added a FlatList that renders the items from the state. The state is fetched via state.expenses.get()

Code
const App = () => {
const expenses = state.expenses.get();
return (
<FlatList
data={expenses}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.expenseItem}>
<Text>
{item.title}: ${item.amount}
</Text>
</View>
)}
/>
);
};

At this point, we’ll see the default two expenses:

Let’s add interactivity to the state via a button that adds a random expense to the list.

Code
const App = () => {
const addExpense = () => {
const newExpense = {
id: Math.random().toString(),
title: `Expense ${expenses.length + 1}`,
amount: Math.floor(Math.random()),
};
state.expenses.set((currentExpenses) => [...currentExpenses, newExpense]);
};
const expenses = state.expenses.get();
return (
<View>
<FlatList /> //stays the same
<Button title="Add Expense" onPress={addExpense} />
</View>
);
};

Finally, let’s wrap any components we want to observe with observer which tracks changes to any observables, triggering a re-render whenever the observable state changes.

Code
import { observable } from "@legendapp/state";
import { observer } from "@legendapp/state/react";
const state = observable({
expenses: [ ... ],
});
const App = observer(() => {
...same code
});

Add persistent storage

Like Redux, Legend State doesn't utilize persistent storage by default. However, for our offline first approach where our app should function while offline, data persistence is essential.

Adding persistent storage was a breeze, thanks in part to the clear and detailed Legend State documentation. For persistent storage in React Native with legend-state, there are two primary integration options: MMKV and AsyncStorage.

Note: If you’re building a universal app for both mobile and web, Legend State supports persistent storage on web as well, via IndexedDB 🔥

In this instance, I opted for AsyncStorage. MMKV has some excellent encryption capabilities that are worth considering.

Install @react-native-async-storage/async-storage and then connect it to Legend State:

Code
import {
configureObservablePersistence,
persistObservable,
} from '@legendapp/state/persist'
import { ObservablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'
import AsyncStorage from '@react-native-async-storage/async-storage'
configureObservablePersistence({
// Use AsyncStorage in React Native
pluginLocal: ObservablePersistAsyncStorage,
localOptions: {
asyncStorage: {
// The AsyncStorage plugin needs to be given the implementation of AsyncStorage
AsyncStorage,
},
},
})
const state = observable({ ... })
persistObservable(state, {
local: 'store', // Unique name
})

Setting up configureObservablePersistence is a straightforward way to tell Legend State to use AsyncStorage for storing data. We use persistObservable in order to mark specific observable states that we want to persist to storage, only needing a unique identifier for each state in AsyncStorage.

And that's it! With this simple config, we now have a fully functioning persistent storage for our app's state.

Add in remote synchronization

Now we want to make this application multiplayer. The goal here is that anyone in my household can add expenses from any device.

In a simple setup, when client A logs a new expense, it should automatically update for client B and C. Additionally, clients B and C need the ability to record expenses while offline, with these records syncing across all devices once they're back online.

Legend State’s persistence plugin architecture not only supports local persistence, but remote persistence, as well. Since there is already a plugin for it, I chose to use Firebase Realtime Database for remote sync. This works with Cloud Firestore, too, but we did realtime because the pricing is better for small consistent changes, as Cloud Firestore charges on operations performed. If you wanted to use a different backend, the persistence plugin system is a straightforward API that can be built upon to support virtually any provider.

After configuring the application on the Firebase web console, I added the Firebase JavaScript SDK, initialized Firebase in my app, and plugged it into Legend State:

Code
import { ObservablePersistFirebase } from "@legendapp/state/persist-plugins/firebase";
//... other imports
initializeApp(firebaseConfig); // copied from Firebase console when creating an app
configureObservablePersistence({...same});
const state = observable(...same);
persistObservable(state, {
local: "store", // Unique name
pluginRemote: ObservablePersistFirebase,
remote: {
onSaveError: (err) => console.error(err),
firebase: {
refPath: () => `/test/`,
},
},
});
const App = observer(() => {
...same
});

And just like that, we have an offline-first app that can effortlessly sync with other devices 👏

Testing Offline and Sync

I tried several different scenarios to test the capabilities of my system:

  • Is there real time sync when a separate device updates the expense list when both devices are online?
  • What happens when a user A adds an expense offline & then resumes connectivity? Does user B immediately receive their update?
  • What happens when a user A adds an expense offline while an online user B adds an additional expense themself? Do both expenses get merged into the array by default?

The positive news is that all scenarios performed as expected: Syncing would happen within a few seconds once connectivity was regained.

By default, if both devices edited the list offline and later reconnected, the system prioritized the last device to regain connectivity, overwriting the other's expense list. This occurred because we stored expenses in a simple array. Switching to a dictionary with time-based keys would prevent this issue.

How to assess local-first effectiveness

A practical way to assess our application’s effectiveness for local-first is by comparing it to Ink & Drop's 7 ideals for Local-first software:

  1. No spinners: your work at your fingertips ✅
  2. Your work is not trapped on one device ✅
  3. The network is optional ✅
  4. Seamless collaboration with your colleagues ✅
  5. The Long Now ✅
  6. Security and privacy by default ⚠️
  7. You retain ultimate ownership and control ❌

The two challenges lie in data ownership & privacy — our server-based sync means you have a copy, but the server has the final say. To truly own your data, we'd need a shift in how the app treats the source of truth.

As Ink & Switch explain in their comparison, Firebase offers us (as developers) a great experience for viewing and editing the data, but for the user, there is no comparable way of examining their own data.

When it comes to security, we entirely skipped adding authentication in our example. But here’s the good news: Firebase comes equipped with robust security rules designed to shield your data from unwelcome eyes.

Wrapping up, diving into Expo and Legend State has shown me that we're onto something big with Local-first apps in React Native. It's all about making apps that work for users, anytime, anywhere.

If you want to read more on the Local-first movement, a good first place to start is with the Ink & Switch white paper. To see an example of a Local-first in production with Expo, Johannes Schickling presented his lessons learned from building a local-first music app:

Local first state management
local first applications
offline applications

Accelerate building apps with Expo and AI

Learn more