Offline-First Mobile Apps with Expo & Zustand
Architecting resilient cross-platform mobile apps with local cache persistence, background sync mechanics, and fluid 60fps gesture animations.
Overview
Offline-first means the app works perfectly without a network connection and seamlessly syncs when connectivity returns. This guide walks through building this pattern with Expo, Zustand, and MMKV for near-instantaneous local storage.
Storage Layer: MMKV over AsyncStorage
MMKV is a C++ key-value store that is ~30× faster than AsyncStorage for most operations.
npx expo install react-native-mmkv
// lib/storage.ts
import { MMKV } from "react-native-mmkv";
export const storage = new MMKV({ id: "app-storage" });
export const mmkvStorage = {
getItem: (key: string) => storage.getString(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
};
Zustand Store with Persistence
// stores/useTaskStore.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { mmkvStorage } from "@/lib/storage";
interface Task {
id: string;
title: string;
completed: boolean;
syncedAt?: number;
dirty: boolean; // pending sync
}
interface TaskStore {
tasks: Task[];
addTask: (title: string) => void;
toggleTask: (id: string) => void;
markSynced: (ids: string[]) => void;
}
export const useTaskStore = create<TaskStore>()(
persist(
(set) => ({
tasks: [],
addTask: (title) =>
set((s) => ({
tasks: [...s.tasks, { id: crypto.randomUUID(), title, completed: false, dirty: true }],
})),
toggleTask: (id) =>
set((s) => ({
tasks: s.tasks.map((t) => (t.id === id ? { ...t, completed: !t.completed, dirty: true } : t)),
})),
markSynced: (ids) =>
set((s) => ({
tasks: s.tasks.map((t) =>
ids.includes(t.id) ? { ...t, dirty: false, syncedAt: Date.now() } : t
),
})),
}),
{ name: "tasks", storage: createJSONStorage(() => mmkvStorage) }
)
);
Background Sync with NetInfo
// hooks/useBackgroundSync.ts
import NetInfo from "@react-native-community/netinfo";
import { useEffect } from "react";
import { useTaskStore } from "@/stores/useTaskStore";
import { api } from "@/lib/api";
export function useBackgroundSync() {
const { tasks, markSynced } = useTaskStore();
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(async (state) => {
if (!state.isConnected) return;
const dirty = tasks.filter((t) => t.dirty);
if (!dirty.length) return;
try {
await api.post("/sync", { tasks: dirty });
markSynced(dirty.map((t) => t.id));
} catch {
// will retry on next connection event
}
});
return unsubscribe;
}, [tasks, markSynced]);
}
60fps Gesture Animations with Reanimated
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
export function SwipeableTask({ task, onDelete }: Props) {
const x = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((e) => { x.value = Math.min(0, e.translationX); })
.onEnd((e) => {
if (e.translationX < -120) {
x.value = withSpring(-300, {}, () => runOnJS(onDelete)(task.id));
} else {
x.value = withSpring(0);
}
});
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={animStyle}>
<TaskRow task={task} />
</Animated.View>
</GestureDetector>
);
}
Key Takeaways
- MMKV is the go-to synchronous storage for Expo apps
- Mark records as
dirty: truelocally; sync on reconnect - Use Reanimated + Gesture Handler for all 60fps interactions — never animate with
Animatedfrom React Native core - Test offline scenarios in Expo Go by toggling airplane mode