Offline-friendly forms in React Native: drafts and a submit queue
Field apps lose signal. Saving drafts as the user types and queueing submissions keeps long forms from being lost.
3 min read
On this page
Long forms meet bad connections
Apps used in the field, by sales agents or surveyors, often have long forms: customer details, documents, signatures. Users fill them in basements, on the road, or in areas with weak signal. Two things go wrong:
- The app is killed or crashes halfway, and twenty minutes of typing is gone.
- The user taps Submit without a connection, and the request fails.
This article covers two small building blocks that solve most of it: auto-saved drafts and a
persistent submit queue. It uses @react-native-async-storage/async-storage,
@react-native-community/netinfo, and react-native-uuid.
1. Auto-save drafts
Save the form state while the user types, debounced so the storage is not written on every key:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useRef, useState } from "react";
export function useDraft<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(initial);
const [restored, setRestored] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Restore once on mount.
useEffect(() => {
AsyncStorage.getItem(key)
.then((saved) => saved && setValue(JSON.parse(saved) as T))
.finally(() => setRestored(true));
}, [key]);
// Save 500 ms after the last change.
useEffect(() => {
if (!restored) return;
clearTimeout(timer.current);
timer.current = setTimeout(() => {
AsyncStorage.setItem(key, JSON.stringify(value));
}, 500);
return () => clearTimeout(timer.current);
}, [key, value, restored]);
const clear = () => AsyncStorage.removeItem(key);
return { value, setValue, restored, clear };
}Use one key per record, for example draft:customer:new or draft:application:${id}, and clear it
after a successful submit:
const draft = useDraft("draft:customer:new", { name: "", phone: "", city: "" });
if (!draft.restored) return <ActivityIndicator />;
<TextInput
value={draft.value.name}
onChangeText={(name) => draft.setValue((v) => ({ ...v, name }))}
/>;Waiting for restored before rendering the form avoids a flash of empty fields and, more
importantly, prevents the empty initial state from overwriting a saved draft.
2. A persistent submit queue
When the user submits, do not call the API directly from the screen. Put the request in a queue that is stored on the device, then try to flush it:
import AsyncStorage from "@react-native-async-storage/async-storage";
import uuid from "react-native-uuid";
type Job = { id: string; url: string; body: unknown; attempts: number };
const QUEUE_KEY = "submit-queue";
async function readQueue(): Promise<Job[]> {
return JSON.parse((await AsyncStorage.getItem(QUEUE_KEY)) ?? "[]") as Job[];
}
async function writeQueue(jobs: Job[]) {
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(jobs));
}
export async function enqueue(url: string, body: unknown) {
// The id doubles as the Idempotency-Key, so a retry never creates a duplicate on the server.
const job: Job = { id: uuid.v4() as string, url, body, attempts: 0 };
await writeQueue([...(await readQueue()), job]);
void flush();
}3. Flush when online
import NetInfo from "@react-native-community/netinfo";
let flushing = false;
export async function flush() {
if (flushing) return;
flushing = true;
try {
for (const job of await readQueue()) {
const state = await NetInfo.fetch();
if (!state.isConnected) break;
const response = await fetch(job.url, {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": job.id },
body: JSON.stringify(job.body),
}).catch(() => null);
const remaining = (await readQueue()).filter((j) => j.id !== job.id);
if (response?.ok || (response && response.status >= 400 && response.status < 500)) {
// Done, or rejected for good: remove it. Surface 4xx errors to the user separately.
await writeQueue(remaining);
} else {
// Network or server error: keep it and try again later.
await writeQueue([...remaining, { ...job, attempts: job.attempts + 1 }]);
break;
}
}
} finally {
flushing = false;
}
}
// Retry whenever the device comes back online.
NetInfo.addEventListener((state) => {
if (state.isConnected) void flush();
});Also call flush() when the app returns to the foreground, using AppState.
Tell the user what is happening
An offline queue is only trustworthy if it is visible. Show a small banner such as "2 submissions waiting for connection", and mark queued records in lists as Pending. Users stop re-entering the same data when they can see it is already saved.
What not to store
AsyncStorage is not encrypted. Do not put tokens, passwords, or sensitive identity documents in
drafts or the queue as plain text. Use the platform keychain or keystore (for example
react-native-keychain) for secrets, and an encrypted store if drafts contain sensitive personal
data. Clear drafts on logout.
Summary
- Auto-save drafts with a debounced hook and restore them before rendering.
- Submit through a persistent queue instead of calling the API from the screen.
- Flush when connectivity returns, with an idempotency key per job.
- Make pending work visible, and keep sensitive data out of plain storage.