Skip to content

Repository files navigation

react-api-state

Offline-first API state management for React with local persistence, automatic synchronization, and optimistic updates.

npm version License: MIT TypeScript

react-api-state is a lightweight TypeScript library for API-backed state management in React.

It is designed for applications where data needs to live locally first, remain available when the network is unavailable, update the UI immediately, and synchronize changes with the server when connectivity returns.

The core idea

                Server API
                    ▲
                    │
              Background Sync
                    │
                    ▼
        ┌────────────────────────┐
        │     react-api-state    │
        │                        │
        │  React State           │
        │  Local Persistence     │
        │  Offline Queue         │
        │  Optimistic Updates    │
        │  Reconciliation        │
        └───────────┬────────────┘
                    │
                    ▼
                 React UI

Unlike a traditional client-state store, react-api-state is built around API-backed collections. It keeps local state responsive while treating the server as the long-term source of truth.

Why use it?

  • Instant UI updates --- mutations update local React state immediately.
  • Offline-first --- changes can be made while disconnected and synchronized later.
  • Persistent --- local data and pending operations survive page reloads.
  • Automatic sync --- pending changes are synchronized when connectivity returns.
  • Optimistic CRUD --- add, update, and delete work against local state first.
  • Conflict-aware reconciliation --- server refreshes don't blindly discard pending local changes.
  • Temporary IDs --- offline-created records can receive real server IDs after synchronization.
  • Queue coalescing --- redundant offline mutations are merged or cancelled before they reach the server.
  • TypeScript-first --- strongly typed hooks, entities, adapters, and API clients.
  • No runtime dependencies --- designed for modern React 18 and React 19.

🚀 Key Features

**

  • Local-First & Optimistic UI: UI updates immediately on local mutations without waiting for server responses.

  • 📦 Offline Persistence: Retains cached entities and pending sync operations across page reloads (built-in LocalStorageAdapter, extensible to IndexedDB).

  • 🔄 Smart Reconciliation: Refresh operations merge fresh server snapshots with pending local changes without destroying un-synced data.

  • 🔀 Operation Queue Coalescing: Automatically merges consecutive updates and cancels redundant offline operations (e.g., CREATE + DELETE).

  • 🆔 Temporary ID Resolution: Generates local temporary IDs for offline entities and transparently replaces them with real database IDs upon server synchronization.

  • 🌐 Automatic Network Synchronization: Listens for browser online events and triggers background synchronization automatically.

  • 🌐 Built-in HTTP API Client: Includes a full-featured HTTP client with token handling, request cancellation, and error event dispatching.

  • 🛡️ Zero Heavy Dependencies: Designed specifically for modern React 18 & React 19 with full TypeScript type safety.

  • 🔀 Multiple Endpoints Aggregation: Concurrently fetch and aggregate data from multiple REST endpoints with Promise.allSettled and fault-tolerant fallback.


**## 🎯 Where react-api-state Fits

React applications often separate three concerns:


Problem Typical tools


Client/application state Zustand, Redux, Jotai

Server-state fetching and caching TanStack Query, SWR

API-backed state with local react-api-state persistence and offline
synchronization

react-api-state focuses on the last category: keeping API-backed collections usable locally while coordinating persistence and synchronization with a remote API.

It is especially useful for applications such as:

  • CRM and business applications
  • Field-service and delivery applications
  • Inventory and order management
  • Offline-capable dashboards
  • Mobile/web applications with intermittent connectivity
  • Applications that need optimistic CRUD operations
  • Local-first workflows backed by a REST API

📦 Installation**

npm install react-api-state

# or

yarn add react-api-state

# or

pnpm add react-api-state

## 💡 Quick Start

import React from "react";

import { useApiState } from "react-api-state";

interface Customer {

_id: string;

name: string;

email: string;

}

export function CustomerList() {

const customers = useApiState<Customer>("/api/customers");

if (customers.loading) return <div>Loading cached
customers...</div>;

return (

    <div>

      {customers.isOffline && <div className="banner">You are currently offline</div>}

      <ul>

        {customers.data.map((c) => (

          <li key={c._id}>

            {c.name} ({c.email})

            <button onClick={() => customers.delete(c._id)}>Delete</button>

          </li>

        ))}

      </ul>

      <button

        onClick={() =>

          customers.add({

            name: "John Doe",

            email: "john@example.com",

          })

        }

      >

        Add Customer

      </button>

    </div>

);

}

## 📖 Hook API Reference

The useApiState<T, S>() hook accepts an endpoint URL string, an array of endpoint URLs (string[]), or a configuration options object (UseApiStateOptions<T, S>), and exposes a strongly-typed object:

Property / Method Type Description
data T[] Current local collection data (optimistically updated).
searchParams `S undefined`
loading boolean true during initial storage load or server refresh.
syncing boolean true while pending operations are being sent to the server.
error `Error null`
isOffline boolean Whether the environment is currently offline (navigator.onLine).
hasPendingChanges boolean true if there are unsynchronized operations in the queue.
get(id) `(id: string) => T undefined`
set(data) (data: T[]) => void Directly replace current local state (does not queue sync operations).
add(data) (data: Partial<T>) => Promise<T> Optimistically add item locally & queue a CREATE operation.
update(id, changes) (id: string, changes: Partial<T>) => Promise<T> Optimistically update item locally & queue an UPDATE operation.
delete(id) (id: string) => Promise<void> Optimistically remove item locally & queue a DELETE operation.
search(params) (params?: S) => Promise<void> Trigger server-side search sending parameters via POST body.
refresh(params?) (params?: S) => Promise<void> Fetch fresh snapshot from server (sends POST body if search params present).
sync() () => Promise<void> Send pending local operations to the server.
clear() () => void Clear local data and operation queue.

## 🔍 Detailed CRUD Example

import { useApiState } from "react-api-state";

interface Task {

_id: string;

title: string;

completed: boolean;

}

export function TaskManager() {

const tasks = useApiState<Task>({

    endpoint: "/api/tasks",

    idField: "_id",

});

// 1. Get single item by ID

const activeTask = tasks.get("task-123");

// 2. Optimistically Add Item

const handleCreate = async () => {

    const newTask = await tasks.add({

      title: "Write documentation",

      completed: false,

    });

    console.log("Created task with temp/real ID:", newTask._id);

};

// 3. Optimistically Update Item

const handleToggle = async (id: string, currentCompleted: boolean) => {

    await tasks.update(id, { completed: !currentCompleted });

};

// 4. Optimistically Delete Item

const handleDelete = async (id: string) => {

    await tasks.delete(id);

};

// 5. Manual Sync & Refresh

return (

    <div>

      <button onClick={() => tasks.refresh()} disabled={tasks.loading}>

        Refresh from Server

      </button>

      <button onClick={() => tasks.sync()} disabled={tasks.syncing || !tasks.hasPendingChanges}>

        {tasks.syncing ? "Syncing..." : "Sync Pending Changes"}

      </button>

      {tasks.error && <p className="error">Sync Error: {tasks.error.message}</p>}

    </div>

);

}

## 🛜 Offline Usage & Background Sync

react-api-state decouples React UI state from network connectivity:

  1. Instant UI Feedback: When offline, mutations (add, update, delete) complete immediately in React local state.

  2. Operation Queueing: Operations are saved to an internal operation queue in local persistence storage.

  3. Auto-Synchronization: When connectivity returns (online browser event), sync() is called automatically.

  4. Status Flags: Components use isOffline and hasPendingChanges to display offline badges or sync indicators.


## 🌐 Using the Built-in API Class

react-api-state includes a full-featured HTTP API class:

import { API } from "react-api-state";

// 1. Initialize API client

const api = new API("https://api.example.com", "my_auth_token_key");

// 2. Set Bearer Token dynamically

api.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6...";

// 3. Perform HTTP operations directly

const customers = await api.get("/customers");

const newCustomer = await api.post("/customers", { name: "Alice" });

await api.patch("/customers/123", { name: "Alice Smith" });

await api.delete("/customers/123");

// 4. Cancel pending request

api.cancel("GET-/customers");

### Passing a Custom API Instance to useApiState

import { API, useApiState } from "react-api-state";

const myApiClient = new API("https://api.example.com");

myApiClient.Token = "my-bearer-token";

export function CustomerApp() {

const customers = useApiState<Customer>({

    endpoint: "/customers",

    api: myApiClient,

});

return <div>{/* UI Components */}</div>;

}

### Default HTTP Methods

By default, react-api-state follows your system conventions:

  • GET [endpoint] to fetch/list all records when no search parameters are present (refresh())

  • POST [endpoint] with request body when search parameters are present (search({ ... }) or refresh({ ... }))

  • PUT [endpoint] to create new records (add())

  • PUT [endpoint]/:id to update existing records (update())

  • DELETE [endpoint]/:id to delete records (delete())

If you want to override methods for alternative REST conventions (e.g. POST for create or PATCH for update):

const customers = useApiState<Customer>({

endpoint: "/api/customers",

method: {

    create: "POST", // Override default PUT to use POST

    update: "PATCH", // Override default PUT to use PATCH

},

});

## 🔎 Server-Side Search (POST Method with Body)

When search parameters are provided, react-api-state automatically executes a POST request to the endpoint with the search parameters as JSON body:

interface SearchFilter {

query?: string;

category?: string;

minPrice?: number;

}

export function ProductCatalog() {

const products = useApiState<Product, SearchFilter>({

    endpoint: "/api/products",

    search: { category: "electronics" }, // Initial search filter (sends POST /api/products)

});

const handleSearch = (text: string) => {

    // Sends POST /api/products with { query: text, category: "electronics" }

    products.search({ query: text, category: "electronics" });

};

return (

    <div>

      <input

        type="text"

        placeholder="Search products..."

        onChange={(e) => handleSearch(e.target.value)}

      />

      {products.loading && <p>Searching...</p>}

      <ul>

        {products.data.map((p) => (

          <li key={p._id}>{p.name} - ₹{p.price}</li>

        ))}

      </ul>

    </div>

);

}

## 🔀 Multiple Endpoints (Aggregated Fetching)

react-api-state supports concurrently fetching and aggregating data from multiple REST endpoints into a unified local state collection.

### 1. Shorthand Array Syntax

You can pass an array of endpoint URLs directly to useApiState:

import { useApiState } from "react-api-state";

interface Product {
  _id: string;
  name: string;
  price: number;
}

export function MultiStoreCatalog() {
  // Concurrently fetch and merge products from multiple endpoints
  const products = useApiState<Product>([
    "/api/store-north/products",
    "/api/store-south/products",
    "/api/store-east/products",
  ]);

  if (products.loading) return <p>Loading catalog from multiple stores...</p>;

  return (
    <ul>
      {products.data.map((item) => (
        <li key={item._id}>
          {item.name} - ₹{item.price}
        </li>
      ))}
    </ul>
  );
}

### 2. Options Object (endpoints field)

You can also pass endpoints in the options configuration object:

const products = useApiState<Product>({
  endpoints: [
    "/api/store-north/products",
    "/api/store-south/products",
  ],
  storageKey: "combined-store-products",
});

### 3. Fault Tolerance with Promise.allSettled

  • Requests to all configured endpoints execute concurrently using Promise.allSettled.
  • If an individual endpoint fails (e.g. 500 error or network unreachable), a warning is logged and the remaining successful endpoints are combined into the collection.
  • Local offline persistence automatically persists the combined dataset.

### 4. Mutations with Multiple Endpoints

Multiple endpoints are designed for data aggregation. Because react-api-state cannot automatically infer which endpoint a mutation (add, update, delete) should target, attempting mutations without explicit mutation endpoints will throw an error.

To perform mutations alongside multiple fetch endpoints, configure explicit mutation URLs:

const products = useApiState<Product>({
  endpoints: [
    "/api/store-north/products",
    "/api/store-south/products",
  ],
  api: {
    create: "/api/store-primary/products",
    update: (id) => `/api/store-primary/products/${id}`,
    delete: (id) => `/api/store-primary/products/${id}`,
  },
});

## ✉️ Response Envelopes & Error Detection

react-api-state handles API envelopes automatically:

### 1. Success Envelopes

If your API wraps success responses:

{

"status": "success",

"message": "You have 10 records",

"data": [

    { "_id": "1", "name": "John" }

]

}

react-api-state automatically unwraps the data array (or items / results) for list(), create(), and update().

### 2. Error Envelopes

If your API returns error responses:

{

"status": "error",

"message": "Authentication failed"

}

react-api-state detects status: "error" (or success: false) and exposes the message string under customers.error.

### 3. Custom Response Transformers

const customers = useApiState<Customer>({

endpoint: "/api/customers",

transformResponse: (res) => res.result.payload,

});

## ⚙️ Configuration Options

interface UseApiStateOptions<T, S = any> {

/** Base REST endpoint string (e.g. "/api/customers") */

endpoint?: string;

/** Array of URLs. Multiple endpoints for fetching data only */

endpoints?: string[];

/** Primary key field name on entities. Default: "_id" */

idField?: keyof T;

/** Initial search parameters. When present, list requests will use POST method with body */

search?: S;

/** Alias for search parameters */

searchParams?: S;

/** Field name for client temporary ID. Default: "tempId" */

tempIdField?: string;

/** Whether to include tempId in create request body to server. Default: true */

sendTempId?: boolean;

/** Custom storage key for persistence. Default: derived from endpoint or endpoints */

storageKey?: string;

/** Storage adapter instance. Default: LocalStorageAdapter */

storage?: StorageAdapter<T>;

/** Custom API adapter instance, API client instance, or endpoint config */

api?: ApiAdapter<T, S> | API | EndpointConfig;

/** Configure HTTP methods for API operations (e.g. create with PUT/POST, update with PUT/PATCH) */

method?: {
  create?: "PUT" | "POST";
  update?: "PUT" | "PATCH";
};

/** Automatically refresh from API on initialization. Default: true */

autoRefresh?: boolean;

/** Automatically sync pending changes when online. Default: true */

autoSync?: boolean;

/** Custom fetch implementation */

fetch?: typeof fetch;

/** Dynamic or static headers */

headers?: Record<string, string> | (() => Promise<Record<string, string>>);

/** Custom temporary ID generator */

generateTempId?: () => string;

}

## 🔀 Operation Queue Coalescing

react-api-state prevents redundant network requests by merging queued mutations before sync:

  • UPDATE + UPDATE: Multiple consecutive updates to the same entity are merged into a single UPDATE with combined properties.

  • CREATE + UPDATE: An offline create followed by updates produces a single CREATE operation with final merged data.

  • CREATE + DELETE: An entity created and deleted while offline cancels both operations, removing them from the queue entirely.

  • UPDATE + DELETE: Preceding updates are discarded and replaced with a single DELETE operation.


## 🆔 Temporary IDs & Mongoose / Backend Deduplication

When creating entities locally/offline, react-api-state generates a client tempId (e.g. local-550e8400-e29b...) instead of forcing a fake string as the primary _id:

const customers = useApiState<Customer>({

endpoint: "/api/customers",

idField: "_id",

tempIdField: "tempId", // Default: "tempId"

});

### 1. What happens during local creation (add):

  • Local entity state gets:
{

    "_id": "local-550e8400-e29b...",

    "tempId": "local-550e8400-e29b...",

    "name": "John Doe"

}
  • The payload sent to the backend includes tempId and omits the fake string _id, so Mongoose generates a clean ObjectId:
{

    "tempId": "local-550e8400-e29b...",

    "name": "John Doe"

}

### 2. Server-side Idempotency & Deduplication:

Your backend / Mongoose can easily check if a request with that tempId was already received to prevent duplicate inserts:

// In your Express / Mongoose controller:

const existing = await Customer.findOne({ tempId: req.body.tempId });

if (existing) return res.json({ status: "success", data: existing });

const customer = await Customer.create(req.body);

res.json({ status: "success", data: customer });

### 3. Automatic Server ID Replacement:

When Mongoose responds with the real database _id (e.g. "64a7f289b0123"):

  • react-api-state updates _id to "64a7f289b0123" while preserving tempId.

  • Updates any subsequent pending operations referencing the temporary ID.

  • Reconciles server snapshots by matching _id OR tempId.


## 🔌 Custom Adapters

### Custom Storage Adapter (e.g. IndexedDB)

import { StorageAdapter, StoredState } from "react-api-state";

import { get, set, del } from "idb-keyval";

export class IndexedDBAdapter<T> implements StorageAdapter<T> {

async load(key: string): Promise<StoredState<T> | null> {

    return (await get(key)) || null;

}

async save(key: string, state: StoredState<T>): Promise<void> {

    await set(key, state);

}

async clear(key: string): Promise<void> {

    await del(key);

}

}

### Custom API Adapter (e.g. GraphQL or Firebase)

import { ApiAdapter } from "react-api-state";

class CustomGraphQLAdapter<T> implements ApiAdapter<T> {

async list(): Promise<T[]> {

    return [];

}

async create(data: Partial<T>): Promise<T> {

    return data as T;

}

async update(id: string, changes: Partial<T>): Promise<T> {

    return { id, ...changes } as unknown as T;

}

async delete(id: string): Promise<void> {}

}

## 🗺️ Roadmap

  • useReference<T>() hook for static master lookup data (countries, categories, departments).

  • Built-in IndexedDbAdapter.

  • Cross-tab sync via BroadcastChannel.

  • Exponential backoff retry strategies.


## 📜 License

MIT © react-api-state contributors

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages