Step‑by‑Step Guide: Building an Offline‑First React Native App with SQLite
Ever tried to show a demo on a commuter train, only to watch the Wi‑Fi drop like a bad habit? That moment taught me the hard way that a smooth user experience can’t depend on a perfect network. Today, more apps need to work offline—whether you’re in a subway tunnel, a remote cabin, or simply on a flaky 4G connection. Building an offline‑first app isn’t a futuristic buzzword; it’s a practical necessity. In this post I’ll walk you through a complete, hands‑on example of a React Native app that stores its data locally with SQLite, and syncs when the network is back.
Why “offline‑first” matters
Users expect reliability
People have grown accustomed to apps that just work. When a banking app freezes because the signal vanished, you lose trust faster than you can say “bug report.” An offline‑first strategy means the app assumes the network will fail, not the other way around.
Bandwidth is cheap, but latency isn’t
Even with cheap data plans, round‑trip latency can kill the feel of a UI. Pulling a list of items from a remote server on every scroll adds a noticeable lag. Caching data locally eliminates that wait.
Legal and privacy constraints
Some industries (healthcare, finance) require data to stay on the device until it can be encrypted and sent to a compliant server. SQLite gives you a portable, zero‑configuration database that lives right inside the app bundle.
The toolbox
- React Native – the cross‑platform framework we all love for writing once, running everywhere.
- react-native-sqlite-storage – a thin wrapper around the native SQLite engine on iOS and Android.
- NetInfo – a React Native module that tells you when the device is online.
- Axios – a promise‑based HTTP client for the occasional sync calls.
- Expo CLI (optional) – makes bootstrapping a new project painless.
All of these are open source, well‑maintained, and play nicely together.
Step 1: Spin up a fresh React Native project
If you’re already in a project, skip this. Otherwise, open a terminal and run:
npx react-native init OfflineFirstDemo
cd OfflineFirstDemo
Or, if you prefer Expo (which handles native linking for you):
npx create-expo-app OfflineFirstDemo
cd OfflineFirstDemo
expo install react-native-sqlite-storage @react-native-community/netinfo axios
The key is to have react-native-sqlite-storage installed. With a bare React Native project you’ll need to run npx pod-install on iOS after installation.
Step 2: Set up the SQLite database
Create a file called src/database.js. This module will open the database, create tables if they don’t exist, and expose simple CRUD helpers.
import SQLite from 'react-native-sqlite-storage';
SQLite.enablePromise(true);
const DB_NAME = 'offline_demo.db';
const TABLE_TODOS = 'todos';
export async function getDBConnection() {
return SQLite.openDatabase({ name: DB_NAME, location: 'default' });
}
export async function createTables(db) {
const query = `
CREATE TABLE IF NOT EXISTS ${TABLE_TODOS} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
`;
await db.executeSql(query);
}
export async function initDB() {
const db = await getDBConnection();
await createTables(db);
return db;
}
A few notes:
- SQLite is a file‑based relational database. No server, no network, just a single file on the device.
- The
updated_atcolumn stores an ISO timestamp. We’ll use it later to decide which records need syncing. INTEGERis used for booleans (0= false,1= true) because SQLite doesn’t have a native boolean type.
Step 3: Build a tiny data‑access layer
Keeping raw SQL scattered throughout UI code quickly becomes a nightmare. Let’s encapsulate it.
Create src/todoRepository.js:
import { initDB } from './database';
export async function getAllTodos() {
const db = await initDB();
const [result] = await db.executeSql(`SELECT * FROM todos ORDER BY updated_at DESC`);
const rows = result.rows;
const todos = [];
for (let i = 0; i < rows.length; i++) {
const item = rows.item(i);
todos.push({
id: item.id,
title: item.title,
completed: !!item.completed,
updatedAt: item.updated_at,
});
}
return todos;
}
export async function addTodo(title) {
const db = await initDB();
const now = new Date().toISOString();
await db.executeSql(
`INSERT INTO todos (title, completed, updated_at) VALUES (?, 0, ?)`,
[title, now]
);
}
export async function toggleTodo(id, completed) {
const db = await initDB();
const now = new Date().toISOString();
await db.executeSql(
`UPDATE todos SET completed = ?, updated_at = ? WHERE id = ?`,
[completed ? 1 : 0, now, id]
);
}
Notice the use of parameterized queries (?). This prevents SQL injection and keeps the code tidy.
Step 4: Detect connectivity
React Native’s NetInfo module emits events whenever the network state changes. Wrap it in a hook for convenience.
Create src/useNetworkStatus.js:
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(false);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(state => {
setIsOnline(state.isConnected && state.isInternetReachable);
});
// Get the initial state
NetInfo.fetch().then(state => {
setIsOnline(state.isConnected && state.isInternetReachable);
});
return () => unsubscribe();
}, []);
return isOnline;
}
Now any component can call const online = useNetworkStatus(); and react accordingly.
Step 5: Wire up UI with local storage
Let’s keep the UI minimal: a list of todos, a text input to add new ones, and a tap to toggle completion.
In App.js:
import React, { useEffect, useState } from 'react';
import {
SafeAreaView,
View,
Text,
TextInput,
TouchableOpacity,
FlatList,
StyleSheet,
} from 'react-native';
import { getAllTodos, addTodo, toggleTodo } from './src/todoRepository';
import { useNetworkStatus } from './src/useNetworkStatus';
export default function App() {
const [todos, setTodos] = useState([]);
const [newTitle, setNewTitle] = useState('');
const isOnline = useNetworkStatus();
const loadTodos = async () => {
const data = await getAllTodos();
setTodos(data);
};
useEffect(() => {
loadTodos();
}, []);
const handleAdd = async () => {
if (!newTitle.trim()) return;
await addTodo(newTitle.trim());
setNewTitle('');
loadTodos();
};
const handleToggle = async (id, completed) => {
await toggleTodo(id, !completed);
loadTodos();
};
const renderItem = ({ item }) => (
<TouchableOpacity
style={styles.item}
onPress={() => handleToggle(item.id, item.completed)}
>
<Text style={item.completed ? styles.completed : null}>
{item.title}
</Text>
</TouchableOpacity>
);
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Offline‑First Todo</Text>
<Text style={styles.status}>
{isOnline ? '🟢 Online' : '🔴 Offline'}
</Text>
</View>
<FlatList
data={todos}
keyExtractor={item => item.id.toString()}
renderItem={renderItem}
contentContainerStyle={styles.list}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="New todo"
value={newTitle}
onChangeText={setNewTitle}
/>
<TouchableOpacity style={styles.addBtn} onPress={handleAdd}>
<Text style={styles.addBtnText}>Add</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, backgroundColor: '#f9f9f9' },
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12 },
title: { fontSize: 24, fontWeight: 'bold' },
status: { fontSize: 16 },
list: { paddingBottom: 80 },
item: { padding: 12, backgroundColor: '#fff', marginBottom: 8, borderRadius: 4 },
completed: { textDecorationLine: 'line-through', color: '#777' },
inputRow: { flexDirection: 'row', position: 'absolute', bottom: 0, left: 0, right: 0, padding: 12, backgroundColor: '#fff' },
input: { flex: 1, borderWidth: 1, borderColor: '#ddd', borderRadius: 4, paddingHorizontal: 8 },
addBtn: { marginLeft: 8, backgroundColor: '#0066cc', borderRadius: 4, justifyContent: 'center', paddingHorizontal: 12 },
addBtnText: { color: '#fff', fontWeight: 'bold' },
});
What you see here is a classic “todo” app, but notice the isOnline flag displayed in the header. The UI never blocks waiting for a network request; everything lives locally. When the device regains connectivity, we’ll push the changes to a remote server.
Step 6: Syncing with the backend
For the purpose of this guide, imagine a simple REST endpoint:
GET /todos– returns an array of todo objects.POST /todos– creates a new todo.PUT /todos/:id– updates a todo.
Create src/syncService.js:
import axios from 'axios';
import { getAllTodos } from './todoRepository';
const API_BASE = 'https://example.com/api';
export async function syncIfOnline(isOnline) {
if (!isOnline) return;
try {
// 1. Pull latest from server
const { data: remoteTodos } = await axios.get(`${API_BASE}/todos`);
// 2. Get local copy
const localTodos = await getAllTodos();
// 3. Simple conflict resolution: latest updated_at wins
const merged = mergeTodos(localTodos, remoteTodos);
// 4. Push any local changes that are newer
for (const todo of merged.toPush) {
await axios.put(`${API_BASE}/todos/${todo.id}`, todo);
}
// 5. Refresh local DB with server state
// (In a real app you’d batch updates, use transactions, etc.)
// For brevity we’ll just replace the table.
// ...implementation omitted...
} catch (e) {
console.warn('Sync failed', e);
}
}
function mergeTodos(local, remote) {
const toPush = [];
const remoteMap = new Map(remote.map(t => [t.id, t]));
for (const l of local) {
const r = remoteMap.get(l.id);
if (!r) {
// Local only – push it
toPush.push(l);
} else if (new Date(l.updatedAt) > new Date(r.updated_at)) {
// Local newer – push
toPush.push(l);
}
remoteMap.delete(l.id);
}
// Anything left in remoteMap is new on the server; you’d insert it locally.
// This demo skips that step.
return { toPush };
}
Now, in App.js, add a useEffect that runs syncIfOnline(isOnline) whenever isOnline flips to true.
import { syncIfOnline } from './src/syncService';
...
useEffect(() => {
if (isOnline) {
syncIfOnline(isOnline).then(loadTodos);
}
}, [isOnline]);
That’s the core of an offline‑first loop: read/write locally, detect connectivity, then reconcile.
Step 7: Test on a real device
Emulators are great, but they often have perfect network conditions. Deploy the app to a phone, turn off Wi‑Fi, add a few todos, then turn the network back on. You should see the “Online” badge appear and the sync routine fire (watch the console for any warnings).
If you ever get the dreaded “database locked” error, it usually means you’re trying to write to SQLite from multiple threads. The react-native-sqlite-storage library queues statements, but in complex apps you may need to wrap writes in a transaction.
A quick anecdote
The first time I tried this pattern, I was on a weekend hike with spotty LTE. I added a note to my shopping list, then the connection died. When I got back to the cabin and the signal returned, the app tried to sync twice because I had wired two useEffect hooks that both called syncIfOnline. The result? Duplicate entries on the server. A simple debounce solved it, but the lesson stuck: offline‑first code can be noisy; keep the sync entry point singular.
Wrapping up
Building an offline‑first React Native app isn’t magic; it’s a series of deliberate choices:
- Store data locally with SQLite.
- Keep UI logic independent of network state.
- Detect connectivity changes and trigger a deterministic sync.
- Resolve conflicts in a predictable way (timestamp wins is a good start).
With those pillars in place, you’ll deliver a product that feels solid whether the user is on a high‑speed Wi‑Fi or stuck in a tunnel. Give it a try, break it, and iterate—offline‑first is a mindset as much as a stack.