Offline-first API state management for React with local persistence, automatic synchronization, and optimistic updates.
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.
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.
- 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, anddeletework 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.
**
-
⚡ 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
onlineevents and triggers background synchronization automatically. -
🌐 Built-in HTTP
APIClient: 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.allSettledand 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
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
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:
-
Instant UI Feedback: When offline, mutations (
add,update,delete) complete immediately in React local state. -
Operation Queueing: Operations are saved to an internal operation queue in local persistence storage.
-
Auto-Synchronization: When connectivity returns (
onlinebrowser event),sync()is called automatically. -
Status Flags: Components use
isOfflineandhasPendingChangesto 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({ ... })orrefresh({ ... })) -
PUT [endpoint]to create new records (add()) -
PUT [endpoint]/:idto update existing records (update()) -
DELETE [endpoint]/:idto 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 singleUPDATEwith combined properties. -
CREATE+UPDATE: An offline create followed by updates produces a singleCREATEoperation 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 singleDELETEoperation.
## 🆔 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
tempIdand omits the fake string_id, so Mongoose generates a cleanObjectId:
{
"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-stateupdates_idto"64a7f289b0123"while preservingtempId. -
Updates any subsequent pending operations referencing the temporary ID.
-
Reconciles server snapshots by matching
_idORtempId.
## 🔌 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