Offline-first forms with React, Service Workers and IndexedDB
Learn how to build and offline forms with React + native JS libraries
Introduction
Imagine you are building a SaSS web application for the construction industry ( or any web app to be used in a field setting ). In this app, supervisors at the job site update project and worker statuses in the database via a form. Now, imagine if they had to travel to multiple sites in a single day and access the app via the web on an iPad. In scenarios like these, you cannot guarantee constant internet connectivity. What if resources critical to the project's success rely on this information? How can we mitigate this risk? One way is to build your app with an offline-first approach.
We will use a combination of React and native JS libraries: service workers and Indexeddb to build an offline-first form on the web.
What are Service Workers?
Service workers are like the secret agents of the web, working behind the scenes. They are an event-driven worker registered against an origin and a path. It is a JavaScript file that can control the web page/site. These scripts run separately from the main browser thread and are non-blocking. They can intercept network requests, cache or retrieve resource requests, and deliver push messages. They also work when offline. This makes them perfect for our offline-first construction SaSS scenario.
Sync Manager and IndexedDB: A Perfect Pair
The Sync Manager API is a native JS feature that helps defer actions until the user has a stable connection. IndexedDB is a low-level API for client-side storage that is significantly more powerful than local storage. They allow web applications to save and sync data seamlessly across user sessions and connectivity changes when used together.
Use Case: Saving Form Data Offline
In our construction app scenario, users enter and try to submit a form. Traditionally, if they lose connection, that data could be lost. However, our setup uses a service worker to intercept the form submission, store the data in IndexedDB, and register a sync with the Sync Manager. When the connection is re-established, the data is sent to the server. Let’s see how this works in code.
Code example
Setup
We will use Bun runtime and Vite as our build tools.
After installing Bun ( see homepage for installation guide ) create a new app using this command.
bun create my-app --template react
Run the development server using:
cd my-app
bun dev
We will start building our application in the App.tsx file inside the src directory.
**Note**: We won't be going over the backend for this example because it is a basic REST API with a GET and POST endpoint for the saved persons. See the GitHub repo for the backend code I used.
Step 1: Register the Service Worker
When the application loads, it checks if the browser supports service workers and, if so, registers a service worker script ( we will create this service-worker.js later ). This registration occurs once the application starts and ensures the service worker is ready to intercept requests and manage caching and sync operations.
interface Person {
firstName: string,
lastName: string,
age: number
}
function App() {
const [people, setPeople] = useState<Person[]>([]);
const { register, handleSubmit } = useForm();
const registerWorker = () => {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js', {scope: '/', type: 'module'})
.then(registration => {
console.log('Service Worker registered: ', registration);
})
.catch(registrationError => {
console.log('Service Worker registration failed: ', registrationError);
});
});
}
}
Step 2: Create a Service Worker File & Setup IndexedDB on install
To create the service worker that we are trying to register in our App.tsx, we will create a file named serviceworker.js at the same directory level as our src
directory. We do this because placing the service worker at a higher or root directory level allows it to have a broader scope, enabling it to intercept requests for more resources across the application. This positioning is crucial for ensuring the service worker can effectively manage caching and network requests across the app.
In this service worker file, during the 'install' event, we initialize IndexedDB. We set up a database called formDataStore
an object store formData
specifically designed to store offline form data. This setup is essential for enabling robust offline capabilities in the app:
// service-worker.js
self.addEventListener('install', async (event) => {
console.log('Service Worker installing...');
await openDB('formDataStore', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('formData')) {
db.createObjectStore('formData', { autoIncrement: true });
console.log('Object store created!');
}
},
});
console.log('Service Worker installed.');
});
Step 3: Intercept Form Submissions
When a form is submitted, the application checks if the device is online. If online, it sends the data directly to the server via an API. If offline, it stores the data in IndexedDB instead.
// App.tsx
const onSubmit = async (data: FieldValues) => {
if (navigator.onLine) {
const response = await fetch(API_URL + '/people',
{ method: "POST", body: JSON.stringify(data) }
)
if (response.ok) {
console.log(response)
setPeople((prevPeople) => [{...data} as Person, ...prevPeople])
}
} else {
await storeFormDataLocally(data);
}
}
Step 4: Store Data Locally
If offline, the form data is stored in IndexedDB. This function storeFormDataLocally
opens a transaction on the formData
object store, writes the data, and then registers a background sync event with the tag sendFormData
, signaling that this data needs to be sent to the server once connectivity is restored.
async function storeFormDataLocally(formData : FieldValues) {
const db = await openDB('formDataStore', 1);
const tx = db.transaction('formData', 'readwrite');
const store = tx.objectStore('formData');
store.put(formData);
await tx.done;
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration : any = await navigator.serviceWorker.ready
try {
await registration.sync.register('sendFormData');
console.log('Sync event registered');
} catch(e) {
console.log('Failed to register sync, will retry on next visit' + e);
}
}
db.close();
}
Step 5: Sync Data with Server
The service worker listens for the 'sync' event. When triggered (which happens automatically when online), it retrieves all saved form data from IndexedDB and attempts to send it to the server. Successful submissions are then deleted from the store to prevent resending.
self.addEventListener('sync', event => {
if (event.tag === 'sendFormData') {
event.waitUntil(sendFormDataToServer());
}
});
Step 6: Send Data to the Server and Clear IndexedDB
The actual data transmission function fetches all entries from the formData
store, sends each entry to the server, and removes the entry from the store upon successful transmission. This keeps the local store clean and up-to-date.
export async function sendFormDataToServer() {
const db = await openDB('formDataStore', 1);
const tx = db.transaction('formData', 'readonly');
const store = tx.objectStore('formData');
const allSavedData = await store.getAll();
console.log('saved form data', allSavedData);
try {
allSavedData.forEach( async (form, index) => {
const response = await fetch('http://localhost:3000/people',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
});
if (response.ok) {
console.log('Data synced with server:', form);
await db.transaction('formData', 'readwrite').objectStore('formData').delete(index);
console.log(`removed form data for: ${form.firstName} ${form.lastName}` )
}
});
} catch (error) {
console.error('Failed to send form data:', error);
}
}
Conclusion
Let's return to our construction SaSS web app scenario. In the fast-paced environment of a construction site, where supervisors frequently transition between various locations, ensuring seamless access to critical project and worker data is essential—even in the face of intermittent internet connectivity.
By adopting an offline-first approach using React, service workers, and IndexedDB, we've effectively tailored our application to meet these unique demands. Ultimately, using these technologies supports continuous workflow, mitigates the risks associated with unreliable connectivity, and guarantees all stakeholders access to the latest information, supporting timely decisions and consistent project progress.
See Github repo here for the full code.