skills/web-pwa-offline-first/examples/core.md

30 KiB

Offline-First Core Examples

Core code examples for offline-first applications. See SKILL.md for concepts.

Extended patterns: See indexeddb.md for database setup and sync.md for conflict resolution and synchronization.


Pattern 1: Syncable Entity Structure

Every entity that needs synchronization must include metadata for tracking sync state.

// ✅ Good Example - Syncable entity with full metadata
interface SyncableEntity {
  id: string;

  // Sync tracking fields
  _syncStatus: "synced" | "pending" | "conflicted";
  _lastModified: number; // Client timestamp
  _serverTimestamp?: number; // Server timestamp for conflict resolution
  _localVersion: string; // Local revision ID (UUID)
  _serverVersion?: string; // Server revision ID
  _deletedAt?: number; // Soft delete timestamp (tombstone)
}

// Named constants for sync status
const SYNC_STATUS = {
  SYNCED: "synced",
  PENDING: "pending",
  CONFLICTED: "conflicted",
} as const;

// Example: Todo entity with sync metadata
interface Todo extends SyncableEntity {
  title: string;
  completed: boolean;
  userId: string;
}

// Factory function for creating syncable entities
function createTodo(title: string, userId: string): Todo {
  return {
    id: crypto.randomUUID(),
    title,
    completed: false,
    userId,
    _syncStatus: SYNC_STATUS.PENDING,
    _lastModified: Date.now(),
    _localVersion: crypto.randomUUID(),
  };
}

Why good: Clear separation of business data and sync metadata, named constants prevent typos, factory function ensures consistent creation, all fields have explicit types

// ❌ Bad Example - No sync metadata
interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

// No way to track:
// - Whether this todo has been synced
// - When it was last modified
// - If there's a conflict
// - If it's been soft-deleted

Why bad: No sync tracking means data can be lost during sync, no conflict detection possible, hard deletes prevent proper multi-device sync


Pattern 2: Repository Pattern for Data Access

Use a repository as the single access point for all data operations, encapsulating local storage and sync queue logic.

// ✅ Good Example - Repository pattern
import type { Table } from "dexie";

interface DataRepository<T extends SyncableEntity> {
  // Reads always from local
  get(id: string): Promise<T | null>;
  getAll(): Promise<T[]>;
  query(filter: (item: T) => boolean): Promise<T[]>;

  // Writes go local first, then queue sync
  save(item: T): Promise<void>;
  delete(id: string): Promise<void>;

  // Sync status
  getSyncStatus(id: string): Promise<SyncStatus>;
  getPendingCount(): Promise<number>;
}

type SyncStatus = "synced" | "pending" | "conflicted" | "error";

class TodoRepository implements DataRepository<Todo> {
  constructor(
    private readonly localDb: Table<Todo, string>,
    private readonly syncQueue: SyncQueue,
  ) {}

  async get(id: string): Promise<Todo | null> {
    const todo = await this.localDb.get(id);
    // Filter out soft-deleted items
    if (todo?._deletedAt) return null;
    return todo ?? null;
  }

  async getAll(): Promise<Todo[]> {
    return this.localDb.filter((todo) => !todo._deletedAt).toArray();
  }

  async save(todo: Todo): Promise<void> {
    const now = Date.now();

    // Update sync metadata
    const updatedTodo: Todo = {
      ...todo,
      _syncStatus: SYNC_STATUS.PENDING,
      _lastModified: now,
      _localVersion: crypto.randomUUID(),
    };

    // 1. Save to local database immediately
    await this.localDb.put(updatedTodo);

    // 2. Queue for background sync
    await this.syncQueue.enqueue({
      type: "UPSERT",
      collection: "todos",
      data: updatedTodo,
      timestamp: now,
    });
  }

  async delete(id: string): Promise<void> {
    const now = Date.now();

    // Soft delete - update with tombstone
    await this.localDb.update(id, {
      _syncStatus: SYNC_STATUS.PENDING,
      _lastModified: now,
      _deletedAt: now,
    });

    // Queue deletion for sync
    await this.syncQueue.enqueue({
      type: "DELETE",
      collection: "todos",
      data: { id },
      timestamp: now,
    });
  }

  async getSyncStatus(id: string): Promise<SyncStatus> {
    const todo = await this.localDb.get(id);
    return todo?._syncStatus ?? "error";
  }

  async getPendingCount(): Promise<number> {
    return this.localDb
      .where("_syncStatus")
      .equals(SYNC_STATUS.PENDING)
      .count();
  }
}

export { TodoRepository };
export type { DataRepository, SyncStatus };

Why good: Single access point for all data operations, encapsulates sync logic, soft deletes enable proper sync, sync status queryable, repository is testable in isolation


Pattern 3: Sync Queue Management

Queue operations when offline and process them reliably when connectivity returns.

// ✅ Good Example - Robust sync queue
interface QueuedOperation {
  id: string;
  type: "CREATE" | "UPDATE" | "DELETE" | "UPSERT";
  collection: string;
  data: unknown;
  timestamp: number;
  retryCount: number;
  lastError?: string;
}

// Named constants
const MAX_RETRY_ATTEMPTS = 5;
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30000;
const BACKOFF_MULTIPLIER = 2;
const JITTER_FACTOR = 0.5;

function calculateBackoff(attempt: number): number {
  const exponentialDelay = Math.min(
    INITIAL_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, attempt),
    MAX_BACKOFF_MS,
  );

  // Add jitter to prevent thundering herd
  const jitter = exponentialDelay * JITTER_FACTOR * (Math.random() * 2 - 1);

  return Math.floor(exponentialDelay + jitter);
}

class SyncQueue {
  private readonly db: LocalDatabase;
  private readonly STORE_NAME = "syncQueue";
  private processing = false;

  constructor(db: LocalDatabase) {
    this.db = db;

    // Listen for online events
    if (typeof window !== "undefined") {
      window.addEventListener("online", () => this.processQueue());
    }
  }

  async enqueue(
    operation: Omit<QueuedOperation, "id" | "retryCount">,
  ): Promise<void> {
    await this.db.add(this.STORE_NAME, {
      ...operation,
      id: crypto.randomUUID(),
      retryCount: 0,
    });

    // Try to process immediately if online
    if (navigator.onLine) {
      this.processQueue();
    }
  }

  async processQueue(): Promise<void> {
    if (this.processing || !navigator.onLine) return;
    this.processing = true;

    try {
      const operations = await this.db.getAll(this.STORE_NAME);

      // Sort by timestamp to maintain order
      operations.sort((a, b) => a.timestamp - b.timestamp);

      for (const op of operations) {
        try {
          await this.executeOperation(op);
          // Success - remove from queue
          await this.db.delete(this.STORE_NAME, op.id);
        } catch (error) {
          await this.handleOperationError(op, error);
        }
      }
    } finally {
      this.processing = false;
    }
  }

  private async executeOperation(op: QueuedOperation): Promise<void> {
    const endpoint = `/api/${op.collection}`;

    const response = await fetch(endpoint, {
      method: this.getHttpMethod(op.type),
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(op.data),
    });

    if (!response.ok) {
      throw new Error(`Sync failed: ${response.status}`);
    }
  }

  private getHttpMethod(type: QueuedOperation["type"]): string {
    const methods: Record<QueuedOperation["type"], string> = {
      CREATE: "POST",
      UPDATE: "PUT",
      DELETE: "DELETE",
      UPSERT: "PUT",
    };
    return methods[type];
  }

  private async handleOperationError(
    op: QueuedOperation,
    error: unknown,
  ): Promise<void> {
    if (op.retryCount >= MAX_RETRY_ATTEMPTS) {
      // Move to dead letter queue or notify user
      console.error(
        `Operation ${op.id} failed after ${MAX_RETRY_ATTEMPTS} retries`,
        error,
      );
      await this.db.delete(this.STORE_NAME, op.id);
      // Emit event for UI to handle
      this.emitSyncFailure(op);
      return;
    }

    // Increment retry count and schedule retry
    const delay = calculateBackoff(op.retryCount);

    await this.db.put(this.STORE_NAME, {
      ...op,
      retryCount: op.retryCount + 1,
      lastError: error instanceof Error ? error.message : "Unknown error",
    });

    // Schedule retry
    setTimeout(() => this.processQueue(), delay);
  }

  private emitSyncFailure(op: QueuedOperation): void {
    window.dispatchEvent(new CustomEvent("sync-failure", { detail: op }));
  }

  async getQueueLength(): Promise<number> {
    const operations = await this.db.getAll(this.STORE_NAME);
    return operations.length;
  }
}

export { SyncQueue };
export type { QueuedOperation };

Why good: Exponential backoff with jitter prevents server overload, retry limits prevent infinite loops, operations sorted by timestamp maintain consistency, dead letter handling for failed operations, event emission for UI feedback


Pattern 4: Network Status Detection

Reliably detect network status and adjust behavior accordingly.

// ✅ Good Example - Robust network status detection
type NetworkStatus = "online" | "offline" | "slow";
type NetworkListener = (status: NetworkStatus) => void;

const SLOW_THRESHOLD_MS = 2000;
const HEALTH_CHECK_INTERVAL_MS = 30000;

class NetworkStatusManager {
  private status: NetworkStatus = navigator.onLine ? "online" : "offline";
  private listeners = new Set<NetworkListener>();
  private healthCheckInterval: ReturnType<typeof setInterval> | null = null;

  constructor() {
    this.setupEventListeners();

    // Initial health check if online
    if (navigator.onLine) {
      this.checkConnectionQuality();
    }
  }

  private setupEventListeners(): void {
    window.addEventListener("online", () => {
      this.updateStatus("online");
      this.checkConnectionQuality();
    });

    window.addEventListener("offline", () => {
      this.updateStatus("offline");
      this.stopHealthCheck();
    });
  }

  private updateStatus(newStatus: NetworkStatus): void {
    if (this.status !== newStatus) {
      this.status = newStatus;
      this.listeners.forEach((listener) => listener(newStatus));
    }
  }

  getStatus(): NetworkStatus {
    return this.status;
  }

  subscribe(listener: NetworkListener): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  async checkConnectionQuality(): Promise<NetworkStatus> {
    if (!navigator.onLine) {
      this.updateStatus("offline");
      return "offline";
    }

    try {
      const start = performance.now();

      // Use a small health endpoint
      const response = await fetch("/api/health", {
        method: "HEAD",
        cache: "no-store",
      });

      const latency = performance.now() - start;

      if (!response.ok) {
        this.updateStatus("offline");
        return "offline";
      }

      const status = latency > SLOW_THRESHOLD_MS ? "slow" : "online";
      this.updateStatus(status);
      return status;
    } catch {
      this.updateStatus("offline");
      return "offline";
    }
  }

  startHealthCheck(): void {
    this.stopHealthCheck();
    this.healthCheckInterval = setInterval(
      () => this.checkConnectionQuality(),
      HEALTH_CHECK_INTERVAL_MS,
    );
  }

  private stopHealthCheck(): void {
    if (this.healthCheckInterval) {
      clearInterval(this.healthCheckInterval);
      this.healthCheckInterval = null;
    }
  }

  destroy(): void {
    this.stopHealthCheck();
    this.listeners.clear();
  }
}

// Singleton instance
const networkStatus = new NetworkStatusManager();

export { networkStatus, NetworkStatusManager };
export type { NetworkStatus, NetworkListener };

Why good: Doesn't rely solely on navigator.onLine (which can be unreliable), actual health check for real connectivity, slow connection detection, proper cleanup methods, event-based updates


Pattern 5: Optimistic UI with Rollback

Update UI immediately and roll back if sync fails.

// ✅ Good Example - Optimistic updates with rollback support
interface PendingChange<T> {
  id: string;
  previousValue: T | null;
  newValue: T;
  timestamp: number;
}

class OptimisticUpdateManager<T extends SyncableEntity> {
  private pendingChanges = new Map<string, PendingChange<T>>();

  async applyOptimistically(
    id: string,
    newValue: T,
    localDb: {
      get: (id: string) => Promise<T | undefined>;
      put: (value: T) => Promise<void>;
      delete: (id: string) => Promise<void>;
    },
    onUpdate: (items: T[]) => void,
    getAllItems: () => Promise<T[]>,
  ): Promise<() => Promise<void>> {
    // Get current value for potential rollback
    const previousValue = (await localDb.get(id)) ?? null;

    // Store pending change
    this.pendingChanges.set(id, {
      id,
      previousValue,
      newValue,
      timestamp: Date.now(),
    });

    // Apply optimistic update
    await localDb.put(newValue);

    // Notify UI
    const allItems = await getAllItems();
    onUpdate(allItems);

    // Return rollback function
    return async () => {
      const pending = this.pendingChanges.get(id);
      if (!pending) return;

      if (pending.previousValue) {
        await localDb.put(pending.previousValue);
      } else {
        await localDb.delete(id);
      }

      this.pendingChanges.delete(id);

      const updatedItems = await getAllItems();
      onUpdate(updatedItems);
    };
  }

  async confirmChange(id: string): Promise<void> {
    this.pendingChanges.delete(id);
  }

  hasPendingChanges(): boolean {
    return this.pendingChanges.size > 0;
  }

  getPendingChangeIds(): string[] {
    return Array.from(this.pendingChanges.keys());
  }
}

export { OptimisticUpdateManager };
export type { PendingChange };

Why good: Stores previous value for rollback, returns rollback function for error handling, tracks all pending changes, supports both update and create scenarios


Pattern 6: Connection-Aware Data Fetching

Fetch from network when online, gracefully fall back to cache when offline.

// ✅ Good Example - Connection-aware fetcher
const FETCH_TIMEOUT_MS = 10000;

interface FetchResult<T> {
  data: T;
  source: "network" | "cache";
  timestamp: number;
}

interface LocalCache {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, timestamp?: number): Promise<void>;
}

async function fetchWithOfflineSupport<T>(
  url: string,
  localCache: LocalCache,
  options: RequestInit = {},
): Promise<FetchResult<T>> {
  const cacheKey = `fetch:${url}`;

  // Check if online
  if (!navigator.onLine) {
    const cached = await localCache.get<{ data: T; timestamp: number }>(
      cacheKey,
    );
    if (cached) {
      return {
        data: cached.data,
        source: "cache",
        timestamp: cached.timestamp,
      };
    }
    throw new Error("Offline and no cached data available");
  }

  try {
    const response = await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const data = (await response.json()) as T;
    const timestamp = Date.now();

    // Cache successful response
    await localCache.set(cacheKey, { data, timestamp });

    return { data, source: "network", timestamp };
  } catch (error) {
    // Try cache on network failure
    const cached = await localCache.get<{ data: T; timestamp: number }>(
      cacheKey,
    );
    if (cached) {
      console.warn("Network failed, using cached data:", error);
      return {
        data: cached.data,
        source: "cache",
        timestamp: cached.timestamp,
      };
    }
    throw error;
  }
}

export { fetchWithOfflineSupport };
export type { FetchResult, LocalCache };

Why good: Returns source metadata for UI indication, caches successful responses, falls back to cache on failure, timeout prevents hanging requests, typed cache interface


Pattern 7: React Hook for Network Status

A hook for reactive network status in components.

Implementation

// hooks/use-network-status.ts
import { useSyncExternalStore, useCallback } from "react";

type NetworkStatus = "online" | "offline" | "slow";

interface NetworkStatusStore {
  getStatus(): NetworkStatus;
  subscribe(listener: () => void): () => void;
}

// Create a singleton store
function createNetworkStatusStore(): NetworkStatusStore {
  let status: NetworkStatus = navigator.onLine ? "online" : "offline";
  const listeners = new Set<() => void>();

  function updateStatus(newStatus: NetworkStatus): void {
    if (status !== newStatus) {
      status = newStatus;
      listeners.forEach((listener) => listener());
    }
  }

  // Setup event listeners
  window.addEventListener("online", () => updateStatus("online"));
  window.addEventListener("offline", () => updateStatus("offline"));

  return {
    getStatus: () => status,
    subscribe: (listener: () => void) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const networkStore = createNetworkStatusStore();

function useNetworkStatus(): NetworkStatus {
  const getSnapshot = useCallback(() => networkStore.getStatus(), []);

  const subscribe = useCallback((callback: () => void) => {
    return networkStore.subscribe(callback);
  }, []);

  // Server snapshot always returns 'online' for SSR
  const getServerSnapshot = useCallback(() => "online" as NetworkStatus, []);

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

export { useNetworkStatus };
export type { NetworkStatus };

Usage

// components/sync-indicator.tsx
import { useNetworkStatus } from "../hooks/use-network-status";

function SyncIndicator() {
  const status = useNetworkStatus();

  return (
    <div role="status" aria-live="polite" data-status={status}>
      {status === "offline" && (
        <span>You are offline. Changes will sync when connected.</span>
      )}
      {status === "slow" && (
        <span>Slow connection detected. Some features may be delayed.</span>
      )}
      {status === "online" && <span>Connected</span>}
    </div>
  );
}

export { SyncIndicator };

Why good: Uses useSyncExternalStore for proper React 18 integration, SSR-safe with server snapshot, semantic data attributes for styling, accessible with role and aria-live


Pattern 8: Pending Sync Counter Hook

Track the number of pending sync operations for UI feedback.

// hooks/use-pending-sync-count.ts
import { useState, useEffect } from "react";

interface SyncQueue {
  getQueueLength(): Promise<number>;
  subscribe(listener: () => void): () => void;
}

function usePendingSyncCount(syncQueue: SyncQueue): number {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Initial fetch
    syncQueue.getQueueLength().then(setCount);

    // Subscribe to changes
    const unsubscribe = syncQueue.subscribe(() => {
      syncQueue.getQueueLength().then(setCount);
    });

    return unsubscribe;
  }, [syncQueue]);

  return count;
}

export { usePendingSyncCount };

Usage with Badge

// components/sync-badge.tsx
import { usePendingSyncCount } from "../hooks/use-pending-sync-count";
import { useSyncQueue } from "../context/sync-context";

function SyncBadge() {
  const syncQueue = useSyncQueue();
  const pendingCount = usePendingSyncCount(syncQueue);

  if (pendingCount === 0) {
    return null;
  }

  return (
    <span
      role="status"
      aria-label={`${pendingCount} changes pending sync`}
      data-testid="pending-sync"
    >
      {pendingCount}
    </span>
  );
}

export { SyncBadge };

Why good: Reactive updates when queue changes, accessible labels, conditional rendering when empty


Pattern 9: Offline-Aware Form Submission

Handle form submissions that work seamlessly online and offline.

// hooks/use-offline-mutation.ts
import { useState, useCallback } from "react";

interface MutationState<T> {
  data: T | null;
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
  error: Error | null;
  isPending: boolean; // Queued but not synced
}

interface MutationOptions<TInput, TOutput> {
  // Save to local database
  localMutation: (input: TInput) => Promise<TOutput>;
  // Queue for server sync
  queueSync: (input: TInput) => Promise<void>;
  // Optional: optimistic update for UI
  onOptimisticUpdate?: (input: TInput) => void;
  // Optional: rollback on local failure
  onRollback?: (input: TInput, error: Error) => void;
}

function useOfflineMutation<TInput, TOutput>(
  options: MutationOptions<TInput, TOutput>,
) {
  const [state, setState] = useState<MutationState<TOutput>>({
    data: null,
    isLoading: false,
    isSuccess: false,
    isError: false,
    error: null,
    isPending: false,
  });

  const mutate = useCallback(
    async (input: TInput) => {
      setState((prev) => ({
        ...prev,
        isLoading: true,
        isError: false,
        error: null,
      }));

      // Optimistic update
      options.onOptimisticUpdate?.(input);

      try {
        // 1. Save to local database (always succeeds unless DB error)
        const result = await options.localMutation(input);

        // 2. Queue for sync (will process when online)
        await options.queueSync(input);

        setState({
          data: result,
          isLoading: false,
          isSuccess: true,
          isError: false,
          error: null,
          isPending: !navigator.onLine,
        });

        return result;
      } catch (error) {
        const err = error instanceof Error ? error : new Error("Unknown error");

        // Rollback optimistic update
        options.onRollback?.(input, err);

        setState({
          data: null,
          isLoading: false,
          isSuccess: false,
          isError: true,
          error: err,
          isPending: false,
        });

        throw error;
      }
    },
    [options],
  );

  const reset = useCallback(() => {
    setState({
      data: null,
      isLoading: false,
      isSuccess: false,
      isError: false,
      error: null,
      isPending: false,
    });
  }, []);

  return {
    ...state,
    mutate,
    reset,
  };
}

export { useOfflineMutation };
export type { MutationState, MutationOptions };

Usage in Form Component

// components/todo-form.tsx
import { useState } from "react";
import { useOfflineMutation } from "../hooks/use-offline-mutation";
import { useTodoRepository } from "../context/repository-context";
import { useSyncQueue } from "../context/sync-context";
import { createTodo } from "../models/todo";
import type { Todo } from "../models/todo";

function TodoForm() {
  const [title, setTitle] = useState("");
  const todoRepository = useTodoRepository();
  const syncQueue = useSyncQueue();

  const { mutate, isLoading, isPending, isError, error, reset } =
    useOfflineMutation<{ title: string }, Todo>({
      localMutation: async ({ title }) => {
        const todo = createTodo(title, "current-user");
        await todoRepository.save(todo);
        return todo;
      },
      queueSync: async ({ title }) => {
        const todo = createTodo(title, "current-user");
        await syncQueue.enqueue({
          type: "CREATE",
          collection: "todos",
          data: todo,
          timestamp: Date.now(),
        });
      },
    });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!title.trim()) return;

    try {
      await mutate({ title });
      setTitle("");
    } catch {
      // Error handled in mutation state
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Add a todo..."
        disabled={isLoading}
        aria-describedby={isError ? "error-message" : undefined}
      />

      <button type="submit" disabled={isLoading || !title.trim()}>
        {isLoading ? "Saving..." : "Add"}
      </button>

      {isPending && (
        <span role="status">Saved locally. Will sync when online.</span>
      )}

      {isError && (
        <span id="error-message" role="alert">
          {error?.message}
          <button type="button" onClick={reset}>
            Dismiss
          </button>
        </span>
      )}
    </form>
  );
}

export { TodoForm };

Why good: Separates local mutation from sync queue, tracks pending state for UI feedback, handles errors gracefully, form remains functional offline


Pattern 10: Offline-First Data Provider

Context provider that manages offline-first data layer.

// context/offline-data-provider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import type { LocalDatabase } from '../db/database';
import type { SyncQueue } from '../sync/sync-queue';
import type { NetworkStatus } from '../hooks/use-network-status';

interface OfflineDataContextValue {
  database: LocalDatabase;
  syncQueue: SyncQueue;
  networkStatus: NetworkStatus;
  isInitialized: boolean;
  pendingCount: number;
  lastSyncTime: number | null;
  forceSync: () => Promise<void>;
}

const OfflineDataContext = createContext<OfflineDataContextValue | null>(null);

interface OfflineDataProviderProps {
  children: ReactNode;
  database: LocalDatabase;
  syncQueue: SyncQueue;
}

function OfflineDataProvider({
  children,
  database,
  syncQueue,
}: OfflineDataProviderProps) {
  const [isInitialized, setIsInitialized] = useState(false);
  const [networkStatus, setNetworkStatus] = useState<NetworkStatus>(
    navigator.onLine ? 'online' : 'offline'
  );
  const [pendingCount, setPendingCount] = useState(0);
  const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);

  // Initialize database and sync queue
  useEffect(() => {
    async function initialize() {
      try {
        setIsInitialized(true);

        const count = await syncQueue.getQueueLength();
        setPendingCount(count);

        if (navigator.onLine) {
          await syncQueue.processQueue();
          setLastSyncTime(Date.now());
        }
      } catch (error) {
        console.error('Failed to initialize offline data layer:', error);
      }
    }

    initialize();
  }, [database, syncQueue]);

  // Listen for network status changes
  useEffect(() => {
    function handleOnline() {
      setNetworkStatus('online');
      syncQueue.processQueue().then(() => {
        setLastSyncTime(Date.now());
      });
    }

    function handleOffline() {
      setNetworkStatus('offline');
    }

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

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

  // Subscribe to queue changes
  useEffect(() => {
    const unsubscribe = syncQueue.subscribe(() => {
      syncQueue.getQueueLength().then(setPendingCount);
    });

    return unsubscribe;
  }, [syncQueue]);

  const forceSync = async () => {
    if (!navigator.onLine) {
      throw new Error('Cannot sync while offline');
    }

    await syncQueue.processQueue();
    setLastSyncTime(Date.now());
  };

  const value: OfflineDataContextValue = {
    database,
    syncQueue,
    networkStatus,
    isInitialized,
    pendingCount,
    lastSyncTime,
    forceSync,
  };

  return (
    <OfflineDataContext.Provider value={value}>
      {children}
    </OfflineDataContext.Provider>
  );
}

function useOfflineData(): OfflineDataContextValue {
  const context = useContext(OfflineDataContext);
  if (!context) {
    throw new Error('useOfflineData must be used within OfflineDataProvider');
  }
  return context;
}

export { OfflineDataProvider, useOfflineData };
export type { OfflineDataContextValue };

App Setup

// app.tsx
import { OfflineDataProvider } from "./context/offline-data-provider";
import { initDatabase } from "./db/database";
import { createSyncQueue } from "./sync/sync-queue";

const database = await initDatabase();
const syncQueue = createSyncQueue(database);

function App() {
  return (
    <OfflineDataProvider database={database} syncQueue={syncQueue}>
      <AppContent />
    </OfflineDataProvider>
  );
}

function AppContent() {
  const { isInitialized, networkStatus, pendingCount } = useOfflineData();

  if (!isInitialized) {
    return <LoadingScreen />;
  }

  return (
    <main>
      <header>
        <SyncIndicator status={networkStatus} pendingCount={pendingCount} />
      </header>
      <TodoList />
    </main>
  );
}

export { App };

Why good: Centralizes offline data management, provides sync state to entire app, handles initialization gracefully, supports force sync for user-triggered sync


Pattern 11: Reactive Local Queries

Subscribe to local database changes for real-time UI updates.

// hooks/use-local-query.ts
import { useState, useEffect, useCallback } from "react";

interface QueryResult<T> {
  data: T[];
  isLoading: boolean;
  error: Error | null;
  refetch: () => Promise<void>;
}

interface QueryOptions<T> {
  queryFn: () => Promise<T[]>;
  // Subscribe to database changes
  subscribe?: (onUpdate: () => void) => () => void;
}

function useLocalQuery<T>(options: QueryOptions<T>): QueryResult<T> {
  const [data, setData] = useState<T[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const fetchData = useCallback(async () => {
    try {
      setIsLoading(true);
      setError(null);
      const result = await options.queryFn();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err : new Error("Query failed"));
    } finally {
      setIsLoading(false);
    }
  }, [options.queryFn]);

  // Initial fetch
  useEffect(() => {
    fetchData();
  }, [fetchData]);

  // Subscribe to updates
  useEffect(() => {
    if (!options.subscribe) return;

    const unsubscribe = options.subscribe(() => {
      fetchData();
    });

    return unsubscribe;
  }, [options.subscribe, fetchData]);

  return {
    data,
    isLoading,
    error,
    refetch: fetchData,
  };
}

export { useLocalQuery };
export type { QueryResult, QueryOptions };

Usage with Dexie Live Query

// hooks/use-todos.ts
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../db/database";

function useTodos(userId: string) {
  const todos = useLiveQuery(
    () =>
      db.todos
        .where("userId")
        .equals(userId)
        .and((todo) => !todo._deletedAt)
        .sortBy("_lastModified"),
    [userId],
  );

  return {
    data: todos ?? [],
    isLoading: todos === undefined,
  };
}

export { useTodos };

Why good: Automatically updates when database changes, supports any query function, handles loading and error states