Why and How to Build Offline-First Apps in React
A guide to architecting resilient React applications that thrive in uncertain network conditions.
Introduction
In a world of spotty 5G, subway tunnels, and remote work, "always online" is a myth. An offline-first architecture flips the traditional web development model on its head. Instead of treating the network as a dependency, it treats the network as an enhancement. This article explores why you should adopt this mindset and provides a technical roadmap for implementing it in React.
Part 1: The "Why" - Benefits of Offline-First
1. Unbeatable User Experience (UX)
Users don't care about your server status or their signal strength; they care about completing their task. An offline-first app loads instantly and responds immediately to user input, syncing data in the background later. This eliminates the dreaded "loading spinner" fatigue.
2. Performance as a Default
By caching assets and data locally, your app serves content from the disk rather than the network. This results in near-instant load times, significantly improving Core Web Vitals (LCP) and perceived performance.
3. Resilience and Reliability
Traditional apps crash or become read-only when the connection drops. Offline-first apps provide full functionality (read and write) regardless of connectivity, building trust with your users.
Part 2: The "How" - Architecture & Tools
To build an offline-first React app, you need to solve three distinct problems:
- Asset Caching: Loading the HTML/JS/CSS without a network.
- State Persistence: Saving application state (data) to the device.
- Synchronization: Sending local changes to the server when online.
Step 1: The Service Worker (Asset Caching)
The Service Worker is the heart of a Progressive Web App (PWA). It acts as a network proxy, intercepting requests and serving cached files.
If you are using Vite, the easiest way is to use the vite-plugin-pwa:
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
}
})
]
})
Step 2: Data Persistence (IndexedDB & React Query)
localStorage is synchronous and limited to 5MB. For offline-first apps, you need IndexedDB. However, using the raw API is painful. The modern approach combines TanStack Query (React Query) with a persistent storage adapter.
This allows you to cache API responses indefinitely and restore them when the app reloads offline.
// npm install @tanstack/react-query @tanstack/react-query-persist-client idb-keyval
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // Keep unused data for 24 hours
},
},
})
const persister = createSyncStoragePersister({
storage: window.localStorage, // Or use idb-keyval for IndexedDB
})
function App() {
return (
)
}
Step 3: Handling Mutations (Optimistic UI)
When a user performs an action (like "Like Post") while offline, you must:
- Update the UI immediately (Optimistic Update).
- Queue the request.
- Retry the request when the connection returns.
React Query handles the mutation lifecycle efficiently:
const mutation = useMutation({
mutationFn: newTodo => axios.post('/todos', newTodo),
onMutate: async (newTodo) => {
// 1. Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos'])
// 3. Optimistically update the cache
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
// 4. Return context for rollback
return { previousTodos }
},
onError: (err, newTodo, context) => {
// 5. Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
// 6. Refetch after error or success to ensure sync
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
Step 4: Detecting Network Status
You should inform the user if they are working offline. You can create a simple hook for this:
import { useState, useEffect } from 'react';
export const useNetworkStatus = () => {
const [isOnline, setOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
};
Next article: Do's and Don'ts of useEffectEvent in React