Introduction: Building an Uninterrupted Experience

Welcome to Chapter 15! In this exciting project, we’re going to roll up our sleeves and build an “Offline-Ready Collaboration Tool” using React. Imagine a world where your internet connection is flaky, or you’re deep in a tunnel, yet your task list or notes app still works perfectly. That’s the magic of offline-first design, and it’s a critical skill for modern web developers.

This chapter will teach you how to architect a React application that remains functional and responsive even when the network is unavailable. We’ll dive into the core technologies that make this possible, such as Service Workers for intelligent caching and network interception, and client-side databases like IndexedDB for persistent data storage. You’ll learn how to provide an “optimistic UI” that reacts instantly to user input, and how to queue changes for synchronization when connectivity returns.

Before we begin, a solid understanding of React fundamentals, including components, state management (like useState and useReducer), and useEffect hooks, will be beneficial. We’ll be building on these concepts to create a robust, resilient application.

Core Concepts for Offline Resilience

Building an application that works offline isn’t just about caching; it’s a fundamental shift in how we think about data flow and user experience. Let’s break down the key ideas.

The Offline-First Philosophy

The traditional web model assumes constant connectivity. If the network drops, your app often breaks. Offline-first flips this on its head: it prioritizes local data and functionality, treating the network as an enhancement, not a requirement.

Why does this matter?

  • Reliability: Users can continue working even in adverse network conditions (trains, planes, spotty Wi-Fi).
  • Performance: Loading data from local storage is often much faster than fetching it over the network.
  • User Experience: No more frustrating “You are offline” messages or spinners that never resolve. The app feels robust and dependable.

Service Workers: The Unsung Heroes

At the heart of any powerful offline-first application is the Service Worker. Think of a Service Worker as a programmable proxy that sits between your web application and the network. It’s a JavaScript file that runs in the background, separate from your main application thread.

What can a Service Worker do?

  • Intercept Network Requests: It can catch requests made by your app and decide whether to serve them from a cache, fetch them from the network, or even generate a response programmatically.
  • Cache Assets: It can store static assets (HTML, CSS, JS, images) and dynamic data, allowing your app to load instantly even without a network.
  • Background Sync: It can defer actions until the user has a stable internet connection.
  • Push Notifications: It enables your app to receive push notifications even when it’s not open.

Service Workers operate based on events (e.g., install, activate, fetch). During installation, they typically cache essential app assets. During activation, they manage old caches. Most importantly, the fetch event listener is where the magic happens, allowing you to implement various caching strategies (e.g., “cache first, then network,” “network first, then cache”).

Data Persistence: IndexedDB to the Rescue

For storing structured data client-side, localStorage is often the first thought, but it has severe limitations:

  • Synchronous: It blocks the main thread, leading to janky UI.
  • Limited Size: Typically 5-10MB.
  • Key-Value Only: Not suitable for complex data structures or queries.

For robust offline applications, we turn to IndexedDB. It’s a low-level API for client-side storage of significant amounts of structured data, including files/blobs. It’s asynchronous, transactional, and supports indexes for efficient querying.

Working directly with the IndexedDB API can be verbose and complex. This is where libraries like Dexie.js come in. Dexie provides a friendly, promise-based wrapper around IndexedDB, making it much easier to define schemas, perform CRUD operations, and manage transactions. We’ll be using Dexie.js for our project.

Optimistic UI Updates and Background Sync

When a user performs an action (like adding a task) while offline, we don’t want them to wait for a network response that might never come. This is where Optimistic UI shines.

  1. Immediate Feedback: Update the UI immediately as if the operation succeeded. This makes the app feel fast and responsive.
  2. Local Persistence: Store the change in IndexedDB.
  3. Queue for Sync: Mark the change as “pending” and queue it for synchronization with the server.
  4. Network Reconnection: When the app detects it’s back online, the Service Worker (or a dedicated sync manager) picks up the queued changes and sends them to the backend API.
  5. Reconciliation: The backend processes the changes. If successful, the local “pending” status is cleared, and the item might be updated with a server-generated ID. If there’s an error, the UI might display a warning, allowing the user to retry.

This flow provides a seamless experience, even when the underlying network is unreliable.

Here’s a simplified mental model of how these pieces interact:

sequenceDiagram participant User participant ReactApp as React App (UI) participant IndexedDB as IndexedDB (Client DB) participant ServiceWorker as Service Worker (Background Sync) participant BackendAPI as Backend API (Server) User->>ReactApp: Action (e.g., Add Task) ReactApp->>IndexedDB: Store Task Locally (temp ID) ReactApp->>ReactApp: Update UI Optimistically ReactApp->>ServiceWorker: Request Background Sync ServiceWorker->>ServiceWorker: Queue Sync Operation Note over ServiceWorker: User goes offline, then comes back online. ServiceWorker->>BackendAPI: Send Queued Task Data BackendAPI-->>ServiceWorker: Server Response (real ID) ServiceWorker->>IndexedDB: Update Local Task (with real ID) ServiceWorker->>ReactApp: Notify App of Sync Success ReactApp->>ReactApp: Update UI (confirm sync)

Conflict Resolution (A Glimpse)

What happens if a user makes changes offline, and another user (or the same user on a different device) makes conflicting changes online? This is a complex problem. For a simple project, we might adopt a “last write wins” strategy. For real-world collaboration tools, more sophisticated techniques like Operational Transformation (OT) or Conflict-free Replicated Data Types (CRDTs) are used, but these are beyond the scope of this introductory project. We’ll keep our conflict resolution simple for now.

Step-by-Step Implementation: Building Our Offline Task Manager

Let’s build a simple task manager that allows us to add and view tasks, even when offline. When online, it will sync with a mock backend.

Step 1: Project Setup and Dependencies

We’ll use Vite, a modern build tool that offers a fantastic developer experience and has excellent support for Progressive Web Apps (PWAs), which are essential for offline capabilities. For React 2026, we’ll assume React v19.0.0 is the latest stable release, focusing on its core principles.

  1. Create a new Vite React Project: Open your terminal and run:

    npm create vite@latest my-offline-tasks -- --template react-ts
    cd my-offline-tasks
    

    Why react-ts? While we might not use complex TypeScript features in this simple project, starting with TypeScript is a modern best practice for better code maintainability and error detection in larger applications.

  2. Install Dependencies: We need Dexie.js for IndexedDB, and optionally, a PWA plugin for Vite to simplify Service Worker integration.

    npm install dexie@^4.0.0 @vitejs/plugin-react@^4.0.0 vite-plugin-pwa@^0.19.0
    
    • dexie@^4.0.0: The latest major version of Dexie.js for robust IndexedDB interactions.
    • @vitejs/plugin-react@^4.0.0: Vite’s official React plugin.
    • vite-plugin-pwa@^0.19.0: A powerful Vite plugin to generate and register Service Workers and manifest files automatically.
  3. Configure Vite for PWA: Open vite.config.ts and add the PWA plugin:

    // vite.config.ts
    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
    import { VitePWA } from 'vite-plugin-pwa' // <-- Import VitePWA
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [
        react(),
        VitePWA({ // <-- Add VitePWA configuration
          registerType: 'autoUpdate',
          includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
          manifest: {
            name: 'Offline Task Manager',
            short_name: 'Tasks',
            description: 'A simple task manager that works offline',
            theme_color: '#ffffff',
            icons: [
              {
                src: 'pwa-192x192.png',
                sizes: '192x192',
                type: 'image/png'
              },
              {
                src: 'pwa-512x512.png',
                sizes: '512x512',
                type: 'image/png'
              },
              {
                src: 'pwa-512x512.png',
                sizes: '512x512',
                type: 'image/png',
                purpose: 'any maskable'
              }
            ]
          },
          workbox: { // Configuration for Workbox (used by VitePWA)
            globPatterns: ['**/*.{js,css,html,ico,png,svg}'], // Cache these file types
            runtimeCaching: [ // Define runtime caching strategies for dynamic content
              {
                urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.startsWith('/api'),
                handler: 'NetworkFirst', // For API calls, try network first, then cache
                options: {
                  cacheName: 'api-cache',
                  expiration: {
                    maxEntries: 10,
                    maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
                  },
                  cacheableResponse: {
                    statuses: [0, 200]
                  }
                }
              }
            ]
          }
        })
      ],
    })
    

    Explanation:

    • registerType: 'autoUpdate' tells the plugin to automatically update the Service Worker when a new version is detected.
    • includeAssets specifies static assets to be pre-cached.
    • manifest defines the Web App Manifest, crucial for installing your PWA and controlling its appearance.
    • workbox configuration leverages Google’s Workbox library (used internally by vite-plugin-pwa) to define powerful caching strategies. We’ve set up a NetworkFirst strategy for our mock /api calls, meaning it will try to fetch from the network, but fall back to a cached version if offline or network fails.

    You’ll need to create some placeholder PWA icons in your public folder (e.g., pwa-192x192.png, pwa-512x512.png). You can generate these easily using online PWA icon generators.

Step 2: Setting up IndexedDB with Dexie.js

Let’s create our database schema and a Dexie instance.

Create a new file src/db.ts (or src/db.js if not using TypeScript):

// src/db.ts
import Dexie, { Table } from 'dexie';

// Define the structure of our Task item
export interface Task {
  id?: string; // Optional for new tasks, will be assigned by DB/server
  title: string;
  completed: boolean;
  isSynced: boolean; // Flag to track if the task has been synced with the backend
  isDeleted: boolean; // Flag for soft deletion
  createdAt: number;
  updatedAt: number;
}

// Extend Dexie to add our tables
export class MyDatabase extends Dexie {
  tasks!: Table<Task>; // 'tasks' is the name of our object store

  constructor() {
    super('OfflineTaskManagerDB'); // Name of our database
    this.version(1).stores({
      tasks: '++id, title, completed, isSynced, isDeleted, createdAt, updatedAt' // Schema definition
    });
    // ++id: auto-incrementing primary key (Dexie will generate a UUID if a string is provided)
    // Other properties are indexed for faster lookups.
  }
}

export const db = new MyDatabase();

Explanation:

  • We define an interface Task to strongly type our task objects (if using TypeScript). Key properties include id, title, completed, and crucial flags like isSynced and isDeleted for managing offline changes.
  • MyDatabase extends Dexie. In its constructor, we define the database name.
  • this.version(1).stores({...}) defines our database schema. version(1) means this is the first version of our schema. If you ever need to change the schema (e.g., add new fields), you’d increment the version number and provide a migration.
  • tasks: '++id, title, ...' declares an object store named tasks. ++id tells Dexie to automatically generate a unique ID for each new task. We also index title, completed, isSynced, isDeleted, createdAt, and updatedAt for efficient querying.

Step 3: Online Status Detection

Knowing when the user is online or offline is crucial for triggering sync operations. Let’s create a simple custom hook for this.

Create src/hooks/useOnlineStatus.ts:

// src/hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react';

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

Explanation:

  • This hook uses useState to track the online status, initialized with navigator.onLine.
  • useEffect adds event listeners for the browser’s online and offline events.
  • The cleanup function removes these listeners when the component unmounts, preventing memory leaks.

Step 4: Building the Task List UI and Local Persistence

Now, let’s create our main application component (App.tsx) to display tasks and allow adding new ones. We’ll manage tasks directly with Dexie.js for local persistence.

Update src/App.tsx:

// src/App.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { db, Task } from './db';
import { useLiveQuery } from 'dexie-react-hooks'; // A helpful hook for React
import { useOnlineStatus } from './hooks/useOnlineStatus';
import './App.css'; // Assuming you have some basic styling

// --- Mock Backend API (for demonstration purposes) ---
const MOCK_API_DELAY = 1000; // Simulate network latency

interface ServerTask {
  id: string; // Server-generated ID
  title: string;
  completed: boolean;
  createdAt: number;
  updatedAt: number;
}

const mockBackend = {
  tasks: [] as ServerTask[],
  async addTask(task: Omit<Task, 'id' | 'isSynced' | 'isDeleted'>): Promise<ServerTask> {
    return new Promise((resolve) => {
      setTimeout(() => {
        const serverId = `server-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
        const newTask: ServerTask = { ...task, id: serverId };
        mockBackend.tasks.push(newTask);
        console.log('Backend: Task added', newTask);
        resolve(newTask);
      }, MOCK_API_DELAY);
    });
  },
  async updateTask(id: string, updates: Partial<ServerTask>): Promise<ServerTask | null> {
    return new Promise((resolve) => {
      setTimeout(() => {
        const index = mockBackend.tasks.findIndex(t => t.id === id);
        if (index > -1) {
          const updatedTask = { ...mockBackend.tasks[index], ...updates, updatedAt: Date.now() };
          mockBackend.tasks[index] = updatedTask;
          console.log('Backend: Task updated', updatedTask);
          resolve(updatedTask);
        } else {
          console.log('Backend: Task not found for update', id);
          resolve(null);
        }
      }, MOCK_API_DELAY);
    });
  },
  async deleteTask(id: string): Promise<boolean> {
    return new Promise((resolve) => {
      setTimeout(() => {
        const initialLength = mockBackend.tasks.length;
        mockBackend.tasks = mockBackend.tasks.filter(t => t.id !== id);
        const deleted = mockBackend.tasks.length < initialLength;
        console.log('Backend: Task deleted', id, deleted);
        resolve(deleted);
      }, MOCK_API_DELAY);
    });
  },
  async getTasks(): Promise<ServerTask[]> {
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log('Backend: Fetching tasks', mockBackend.tasks);
        resolve([...mockBackend.tasks]);
      }, MOCK_API_DELAY);
    });
  }
};
// --- END Mock Backend API ---

function App() {
  const [newTaskTitle, setNewTaskTitle] = useState('');
  const isOnline = useOnlineStatus();

  // useLiveQuery from dexie-react-hooks keeps our component updated
  // whenever data in the 'tasks' table changes.
  const tasks = useLiveQuery(
    () => db.tasks.where('isDeleted').equals(false).sortBy('createdAt'),
    []
  );

  // Function to add a new task (optimistic update)
  const addTask = useCallback(async () => {
    if (!newTaskTitle.trim()) return;

    const tempId = `temp-${Date.now()}`; // Temporary ID for optimistic UI
    const now = Date.now();
    const newTask: Task = {
      id: tempId,
      title: newTaskTitle.trim(),
      completed: false,
      isSynced: false,      // Mark as not synced yet
      isDeleted: false,
      createdAt: now,
      updatedAt: now,
    };

    try {
      // 1. Optimistically add to local IndexedDB
      await db.tasks.add(newTask);
      setNewTaskTitle(''); // Clear input immediately
      console.log('UI: Added task optimistically:', newTask);

      // 2. Queue for sync (we'll implement the actual sync process next)
      // For now, let's just log that it's queued.
      console.log(`Sync Manager: Task "${newTask.title}" queued for sync.`);

    } catch (error) {
      console.error('Error adding task locally:', error);
      // Handle error: maybe revert UI, show message
    }
  }, [newTaskTitle]);

  // Function to toggle task completion (optimistic update)
  const toggleTaskCompletion = useCallback(async (id: string, completed: boolean) => {
    try {
      // 1. Optimistically update in local IndexedDB
      await db.tasks.update(id, { completed, isSynced: false, updatedAt: Date.now() });
      console.log('UI: Toggled task completion optimistically:', id, completed);

      // 2. Queue for sync
      console.log(`Sync Manager: Task ${id} completion toggled, queued for sync.`);
    } catch (error) {
      console.error('Error toggling task locally:', error);
    }
  }, []);

  // Function to soft delete a task (optimistic update)
  const deleteTask = useCallback(async (id: string) => {
    try {
      // 1. Optimistically mark as deleted in local IndexedDB
      await db.tasks.update(id, { isDeleted: true, isSynced: false, updatedAt: Date.now() });
      console.log('UI: Marked task as deleted optimistically:', id);

      // 2. Queue for sync
      console.log(`Sync Manager: Task ${id} marked for deletion, queued for sync.`);
    } catch (error) {
      console.error('Error deleting task locally:', error);
    }
  }, []);

  // Effect to load initial tasks from mock backend if online and DB is empty
  useEffect(() => {
    const loadInitialTasks = async () => {
      if (isOnline && tasks && tasks.length === 0) {
        console.log('App: Online and no local tasks, fetching from backend...');
        try {
          const serverTasks = await mockBackend.getTasks();
          for (const sTask of serverTasks) {
            const task: Task = {
              id: sTask.id,
              title: sTask.title,
              completed: sTask.completed,
              isSynced: true, // Already synced
              isDeleted: false,
              createdAt: sTask.createdAt,
              updatedAt: sTask.updatedAt,
            };
            // Use put to add or update if it somehow exists
            await db.tasks.put(task);
          }
          console.log('App: Initial tasks loaded from backend.');
        } catch (error) {
          console.error('App: Failed to load initial tasks from backend:', error);
        }
      }
    };
    loadInitialTasks();
  }, [isOnline, tasks]); // Depend on isOnline and tasks count

  return (
    <div className="app-container">
      <h1>Offline Task Manager</h1>
      <p>Status: {isOnline ? 'Online โœ…' : 'Offline ๐Ÿ”ด'}</p>

      <div className="task-input">
        <input
          type="text"
          placeholder="New task title"
          value={newTaskTitle}
          onChange={(e) => setNewTaskTitle(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && addTask()}
        />
        <button onClick={addTask}>Add Task</button>
      </div>

      <div className="task-list">
        {tasks === undefined ? (
          <p>Loading tasks...</p>
        ) : tasks.length === 0 ? (
          <p>No tasks yet!</p>
        ) : (
          tasks.map((task) => (
            <div key={task.id} className={`task-item ${task.completed ? 'completed' : ''}`}>
              <input
                type="checkbox"
                checked={task.completed}
                onChange={() => toggleTaskCompletion(task.id!, !task.completed)}
              />
              <span>{task.title}</span>
              <span className="sync-status">
                {!task.isSynced && (
                  <span title="Pending sync">๐Ÿ”„</span>
                )}
                {task.isDeleted && (
                  <span title="Pending deletion">๐Ÿ—‘๏ธ</span>
                )}
              </span>
              <button onClick={() => deleteTask(task.id!)} className="delete-button">Delete</button>
            </div>
          ))
        )}
      </div>
    </div>
  );
}

export default App;

You’ll also need some basic CSS. Create src/App.css:

/* src/App.css */
body {
  font-family: Arial, sans-serif;
  background-color: #f4f7f6;
  color: #333;
  margin: 0;
  padding: 20px;
}

.app-container {
  max-width: 600px;
  margin: 40px auto;
  background-color: #fff;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

h1 {
  text-align: center;
  color: #2c3e50;
  margin-bottom: 30px;
}

p {
  text-align: center;
  margin-bottom: 20px;
}

.task-input {
  display: flex;
  gap: 10px;
  margin-bottom: 30px;
}

.task-input input[type="text"] {
  flex-grow: 1;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}

.task-input button {
  padding: 12px 20px;
  background-color: #28a745;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.2s ease;
}

.task-input button:hover {
  background-color: #218838;
}

.task-list {
  border-top: 1px solid #eee;
  padding-top: 20px;
}

.task-item {
  display: flex;
  align-items: center;
  padding: 15px 0;
  border-bottom: 1px solid #eee;
}

.task-item:last-child {
  border-bottom: none;
}

.task-item input[type="checkbox"] {
  margin-right: 15px;
  width: 20px;
  height: 20px;
  cursor: pointer;
}

.task-item span {
  flex-grow: 1;
  font-size: 18px;
  color: #555;
}

.task-item.completed span {
  text-decoration: line-through;
  color: #888;
}

.task-item .sync-status {
  margin-left: 10px;
  font-size: 1.2em;
  color: #ffc107; /* Yellow for pending */
}

.task-item .sync-status[title="Pending deletion"] {
  color: #dc3545; /* Red for pending deletion */
}

.delete-button {
  background-color: #dc3545;
  color: white;
  border: none;
  padding: 8px 12px;
  border-radius: 4px;
  cursor: pointer;
  margin-left: 15px;
  transition: background-color 0.2s ease;
}

.delete-button:hover {
  background-color: #c82333;
}

Explanation of App.tsx:

  • Mock Backend: We’ve included a simple mockBackend object to simulate API calls. In a real application, this would be actual network requests to a server.
  • useLiveQuery: This powerful hook from dexie-react-hooks allows our React component to automatically re-render whenever the data in the db.tasks table changes. It’s like useState but for IndexedDB queries!
  • useOnlineStatus: Our custom hook tells us if the user is currently online.
  • Optimistic UI for addTask:
    1. A temporary id is generated for the new task.
    2. The task is immediately added to db.tasks using db.tasks.add().
    3. The input field is cleared. The UI updates instantly thanks to useLiveQuery.
    4. The isSynced flag is set to false, indicating it needs to be sent to the server.
  • toggleTaskCompletion and deleteTask: These functions follow a similar optimistic pattern, updating the local database first and marking the task as unsynced (isSynced: false) or deleted (isDeleted: true).
  • Initial Load useEffect: When the app loads and is online, it checks if there are any tasks locally. If not, it fetches tasks from our mock backend and populates IndexedDB, ensuring a consistent starting point.
  • UI Elements: Displays tasks, their completion status, and a small icon (๐Ÿ”„ or ๐Ÿ—‘๏ธ) if they are pending synchronization or deletion.

Now, run npm run dev and open your browser. You should be able to add tasks. To test offline behavior:

  1. Open Developer Tools (F12).
  2. Go to the “Network” tab.
  3. Change the “No throttling” dropdown to “Offline.”
  4. Try adding tasks. They should appear instantly!
  5. Refresh the page while offline. The tasks should still be there, loaded from IndexedDB.

Step 5: Implementing the Sync Mechanism

Our app can add tasks offline, but they never reach the “server.” Let’s create a sync function that runs when the app comes online. For simplicity, we’ll use an useEffect in App.tsx that triggers when isOnline changes. For production, you might use the actual Background Sync API (which is Service Worker-based) or a more robust queueing system.

Add the following useEffect to App.tsx (within the App component, after other useEffects):

// src/App.tsx (inside the App component)
// ... other code ...

  // Effect to handle synchronization when online
  useEffect(() => {
    const syncPendingChanges = async () => {
      if (!isOnline) {
        console.log('Sync Manager: Currently offline, skipping sync.');
        return;
      }

      console.log('Sync Manager: Online, checking for pending changes...');
      const pendingTasks = await db.tasks.where('isSynced').equals(false).toArray();

      if (pendingTasks.length === 0) {
        console.log('Sync Manager: No pending changes to sync.');
        return;
      }

      console.log(`Sync Manager: Found ${pendingTasks.length} pending tasks. Starting sync...`);

      for (const task of pendingTasks) {
        try {
          if (task.isDeleted) {
            // Handle deletion
            if (task.id?.startsWith('temp-')) {
              // Task was created offline and immediately deleted offline.
              // No need to send to server, just remove from local DB.
              await db.tasks.delete(task.id);
              console.log(`Sync Manager: Removed temp-deleted task from local DB: ${task.id}`);
            } else if (task.id) {
              // Task existed on server, now marked for deletion.
              const success = await mockBackend.deleteTask(task.id);
              if (success) {
                await db.tasks.delete(task.id);
                console.log(`Sync Manager: Deleted task ${task.id} on server and locally.`);
              } else {
                console.warn(`Sync Manager: Server reported task ${task.id} not found for deletion.`);
                // Decide how to handle: maybe keep local, or force delete
                await db.tasks.delete(task.id); // For this project, assume server eventually cleans up
              }
            }
          } else if (task.id?.startsWith('temp-')) {
            // New task created offline, needs to be added to server
            const { id: tempId, isSynced, isDeleted, ...taskData } = task; // Exclude tempId and sync flags
            const serverTask = await mockBackend.addTask(taskData);
            await db.tasks.update(tempId, {
              id: serverTask.id, // Update with server-generated ID
              isSynced: true,
              updatedAt: serverTask.updatedAt,
            });
            console.log(`Sync Manager: Synced new task "${task.title}". Temp ID: ${tempId} -> Server ID: ${serverTask.id}`);
          } else if (task.id) {
            // Existing task updated offline
            const { id, isSynced, isDeleted, createdAt, ...updates } = task; // Send only changed fields
            const serverTask = await mockBackend.updateTask(id, updates);
            if (serverTask) {
              await db.tasks.update(id, { isSynced: true, updatedAt: serverTask.updatedAt });
              console.log(`Sync Manager: Synced update for task "${task.title}" (ID: ${task.id})`);
            } else {
              console.warn(`Sync Manager: Server reported task ${task.id} not found for update. Assuming deleted or conflict.`);
              // Conflict resolution: For now, we'll just mark as synced and proceed.
              // In a real app, you'd fetch latest from server and merge/resolve.
              await db.tasks.update(id, { isSynced: true, isDeleted: true }); // Mark as deleted locally if not on server
            }
          }
        } catch (error) {
          console.error(`Sync Manager: Failed to sync task ${task.id}:`, error);
          // If a sync fails, we leave isSynced: false so it's retried later.
          // Could implement exponential backoff or specific error handling.
        }
      }
      console.log('Sync Manager: Sync process completed.');
    };

    // Debounce the sync call to avoid hammering the backend on rapid online/offline toggles
    const timeoutId = setTimeout(() => {
        syncPendingChanges();
    }, 500); // Wait 500ms after online status changes

    return () => clearTimeout(timeoutId);

  }, [isOnline]); // Trigger sync when online status changes

// ... rest of the App component ...

Explanation of the Sync useEffect:

  • This useEffect runs whenever the isOnline status changes.
  • It first checks if the app is online. If not, it bails out.
  • It then queries db.tasks for any tasks where isSynced is false. These are our pending changes.
  • It iterates through pendingTasks:
    • Deletion Handling: If task.isDeleted is true:
      • If it’s a temp- ID, it means the task was created and deleted offline. We simply remove it from IndexedDB as it never existed on the server.
      • If it has a server ID, we attempt to mockBackend.deleteTask. If successful, we remove it from IndexedDB.
    • New Task Handling: If the id starts with temp-, it’s a new task created offline. We send it to mockBackend.addTask, and upon success, we update the local IndexedDB entry with the server-generated id and set isSynced to true.
    • Update Handling: If it has a server id and isDeleted is false, it’s an existing task that was modified offline. We send the updates to mockBackend.updateTask and set isSynced to true.
  • Error Handling: Each API call is wrapped in a try...catch block. If a sync fails, the isSynced: false flag remains, ensuring it will be retried on the next online event.
  • Debouncing: A setTimeout is used to debounce the syncPendingChanges call. This prevents the sync logic from running excessively if the isOnline status rapidly toggles (e.g., during a brief network flicker).

To Test the Sync:

  1. Run npm run dev.
  2. Open your app in the browser, and open Developer Tools.
  3. Go to the “Network” tab, and set it to “Offline.”
  4. Add a few tasks. Notice the ๐Ÿ”„ icon.
  5. Refresh the page. Tasks are still there (from IndexedDB).
  6. Now, switch the “Network” tab back to “Online.”
  7. Observe the console logs. You should see messages indicating the “Sync Manager” is processing pending tasks, sending them to the “Backend,” and updating local IDs. The ๐Ÿ”„ icons should disappear.
  8. Refresh the page while online. The tasks should still be there, now fully synced.

You’ve just built an offline-first, optimistically updating, and syncing React application!

Mini-Challenge: Offline Task Editing

It’s your turn to extend our offline-ready task manager!

Challenge: Implement an “edit task” feature. When a user clicks an “Edit” button next to a task, they should be able to modify its title. This change should:

  1. Update optimistically in the UI.
  2. Persist locally in IndexedDB.
  3. Be marked as isSynced: false.
  4. Be synchronized with the mockBackend.updateTask when the app comes online.

Hint:

  • You’ll need a new state variable in App.tsx to track which task is currently being edited (e.g., editingTaskId: string | null).
  • When a task is in edit mode, display an input field instead of the span for its title.
  • Create a new handleEditTask function that updates the task in db.tasks with the new title and sets isSynced: false, similar to toggleTaskCompletion.
  • Remember to update the updatedAt field.

What to observe/learn: Pay close attention to how the useLiveQuery hook naturally handles UI updates once you modify data in IndexedDB. Also, observe how your sync logic correctly identifies and pushes these updated tasks to the backend.

Common Pitfalls & Troubleshooting

  1. Service Worker Not Updating:

    • Issue: You deploy new code, but users still see the old version, even after refreshing.
    • Reason: Service Workers are designed to be persistent. Once installed, they control caching, and a new Service Worker won’t take over until the old one is “deactivated” and all pages controlled by it are closed.
    • Fix: vite-plugin-pwa handles most of this with registerType: 'autoUpdate'. However, in development, it’s often useful to:
      • In Developer Tools (Application -> Service Workers), check “Update on reload” and “Bypass for network” (for debugging).
      • Manually click “Update” and then “skipWaiting()” for the new Service Worker to activate immediately.
      • For production, ensure your users are prompted to refresh or that your app handles Service Worker updates gracefully (e.g., showing a “New version available” toast).
  2. IndexedDB Schema Migrations:

    • Issue: You add a new field to your Task interface (e.g., priority), but your app crashes or doesn’t store the new field.
    • Reason: IndexedDB schemas are strict. Changing your db.version without defining a migration path will cause errors.
    • Fix: When you change your schema, increment the version number in db.ts and provide a migration block:
      export class MyDatabase extends Dexie {
        // ...
        constructor() {
          super('OfflineTaskManagerDB');
          this.version(1).stores({ // Existing schema
            tasks: '++id, title, completed, isSynced, isDeleted, createdAt, updatedAt'
          });
          this.version(2).stores({ // New version with migration
            tasks: '++id, title, completed, isSynced, isDeleted, createdAt, updatedAt, priority' // Added priority
          }).upgrade(tx => {
            // Optional: Migrate existing data if needed
            // await tx.table('tasks').toCollection().modify(task => {
            //   task.priority = 'normal'; // Set default for existing tasks
            // });
          });
        }
      }
      
      Always test migrations thoroughly!
  3. Conflict Resolution Complexity:

    • Issue: Two users modify the same task offline, leading to data loss or unexpected behavior when syncing.
    • Reason: Our current “last write wins” approach is simple but naive.
    • Fix: For a real collaboration tool, you’d need a more robust strategy:
      • Version Vectors/Timestamps: Track versions of each data item and merge changes based on a defined logic.
      • CRDTs (Conflict-free Replicated Data Types): Data structures that can be concurrently updated by multiple users without needing complex coordination, ensuring eventual consistency. Libraries like Yjs or Automerge implement these.
      • User Intervention: Present conflicts to the user and let them decide which version to keep.
    • Troubleshooting: If you see unexpected data states after multiple offline modifications, it’s likely a conflict resolution issue. Start by logging updatedAt timestamps during sync to understand the order of operations.

Summary

Congratulations! You’ve successfully built an offline-ready React application. In this chapter, we covered:

  • The Offline-First philosophy and why it’s crucial for modern web development.
  • The role of Service Workers as network proxies for caching and background operations.
  • How to use IndexedDB for robust client-side data persistence, leveraging Dexie.js for a developer-friendly experience.
  • Implementing Optimistic UI updates to provide immediate feedback to the user.
  • Designing a sync mechanism to reconcile local changes with a remote backend when connectivity is restored.
  • Key considerations for PWA configuration using vite-plugin-pwa.
  • Common challenges like Service Worker updates, IndexedDB schema migrations, and the complexities of conflict resolution.

This project demonstrates how thoughtful architectural choices, combined with powerful web APIs, can create highly resilient and user-friendly applications that truly work anywhere, anytime.

What’s Next?

In the next chapter, we’ll shift our focus to Chapter 16: Performance SLO-Driven UI Design. We’ll explore how to define and measure performance Service Level Objectives (SLOs) for your UI, and how to architect your React applications to consistently meet those targets, ensuring a fast and fluid user experience.

References

  1. MDN Web Docs: Using Service Workers: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
  2. MDN Web Docs: IndexedDB API: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
  3. Dexie.js Official Documentation: https://dexie.org/
  4. Vite PWA Plugin Documentation: https://vite-pwa-org.netlify.app/
  5. React.dev Official Documentation: https://react.dev/

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.