Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

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:

  1. The app is killed or crashes halfway, and twenty minutes of typing is gone.
  2. 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.