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:

  1. Asset Caching: Loading the HTML/JS/CSS without a network.
  2. State Persistence: Saving application state (data) to the device.
  3. 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.

Tool Recommendation: Use Workbox. It is the industry standard for generating service workers with strategies like Stale-While-Revalidate or Cache-First.

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:

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