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.
- Immediate Feedback: Update the UI immediately as if the operation succeeded. This makes the app feel fast and responsive.
- Local Persistence: Store the change in IndexedDB.
- Queue for Sync: Mark the change as “pending” and queue it for synchronization with the server.
- 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.
- 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:
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.
Create a new Vite React Project: Open your terminal and run:
npm create vite@latest my-offline-tasks -- --template react-ts cd my-offline-tasksWhy
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.Install Dependencies: We need
Dexie.jsfor 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.0dexie@^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.
Configure Vite for PWA: Open
vite.config.tsand 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.includeAssetsspecifies static assets to be pre-cached.manifestdefines the Web App Manifest, crucial for installing your PWA and controlling its appearance.workboxconfiguration leverages Google’s Workbox library (used internally byvite-plugin-pwa) to define powerful caching strategies. We’ve set up aNetworkFirststrategy for our mock/apicalls, 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
publicfolder (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 Taskto strongly type our task objects (if using TypeScript). Key properties includeid,title,completed, and crucial flags likeisSyncedandisDeletedfor managing offline changes. MyDatabaseextendsDexie. 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 namedtasks.++idtells Dexie to automatically generate a unique ID for each new task. We also indextitle,completed,isSynced,isDeleted,createdAt, andupdatedAtfor 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
useStateto track the online status, initialized withnavigator.onLine. useEffectadds event listeners for the browser’sonlineandofflineevents.- 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
mockBackendobject to simulate API calls. In a real application, this would be actual network requests to a server. useLiveQuery: This powerful hook fromdexie-react-hooksallows our React component to automatically re-render whenever the data in thedb.taskstable changes. It’s likeuseStatebut for IndexedDB queries!useOnlineStatus: Our custom hook tells us if the user is currently online.- Optimistic UI for
addTask:- A temporary
idis generated for the new task. - The task is immediately added to
db.tasksusingdb.tasks.add(). - The input field is cleared. The UI updates instantly thanks to
useLiveQuery. - The
isSyncedflag is set tofalse, indicating it needs to be sent to the server.
- A temporary
toggleTaskCompletionanddeleteTask: 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:
- Open Developer Tools (F12).
- Go to the “Network” tab.
- Change the “No throttling” dropdown to “Offline.”
- Try adding tasks. They should appear instantly!
- 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
useEffectruns whenever theisOnlinestatus changes. - It first checks if the app is online. If not, it bails out.
- It then queries
db.tasksfor any tasks whereisSyncedisfalse. These are our pending changes. - It iterates through
pendingTasks:- Deletion Handling: If
task.isDeletedis 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.
- If it’s a
- New Task Handling: If the
idstarts withtemp-, it’s a new task created offline. We send it tomockBackend.addTask, and upon success, we update the local IndexedDB entry with the server-generatedidand setisSyncedtotrue. - Update Handling: If it has a server
idandisDeletedis false, it’s an existing task that was modified offline. We send the updates tomockBackend.updateTaskand setisSyncedtotrue.
- Deletion Handling: If
- Error Handling: Each API call is wrapped in a
try...catchblock. If a sync fails, theisSynced: falseflag remains, ensuring it will be retried on the next online event. - Debouncing: A
setTimeoutis used to debounce thesyncPendingChangescall. This prevents the sync logic from running excessively if theisOnlinestatus rapidly toggles (e.g., during a brief network flicker).
To Test the Sync:
- Run
npm run dev. - Open your app in the browser, and open Developer Tools.
- Go to the “Network” tab, and set it to “Offline.”
- Add a few tasks. Notice the
๐icon. - Refresh the page. Tasks are still there (from IndexedDB).
- Now, switch the “Network” tab back to “Online.”
- 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. - 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:
- Update optimistically in the UI.
- Persist locally in IndexedDB.
- Be marked as
isSynced: false. - Be synchronized with the
mockBackend.updateTaskwhen the app comes online.
Hint:
- You’ll need a new state variable in
App.tsxto track which task is currently being edited (e.g.,editingTaskId: string | null). - When a task is in edit mode, display an
inputfield instead of thespanfor its title. - Create a new
handleEditTaskfunction that updates the task indb.taskswith the new title and setsisSynced: false, similar totoggleTaskCompletion. - Remember to update the
updatedAtfield.
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
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-pwahandles most of this withregisterType: '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).
IndexedDB Schema Migrations:
- Issue: You add a new field to your
Taskinterface (e.g.,priority), but your app crashes or doesn’t store the new field. - Reason: IndexedDB schemas are strict. Changing your
db.versionwithout defining a migration path will cause errors. - Fix: When you change your schema, increment the
versionnumber indb.tsand provide a migration block:Always test migrations thoroughly!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 // }); }); } }
- Issue: You add a new field to your
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
updatedAttimestamps 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
- MDN Web Docs: Using Service Workers: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
- MDN Web Docs: IndexedDB API: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
- Dexie.js Official Documentation: https://dexie.org/
- Vite PWA Plugin Documentation: https://vite-pwa-org.netlify.app/
- React.dev Official Documentation: https://react.dev/
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.