@zuiio/query
v0.1.0
Published
TanStack Query v5 + React 19 mutation helpers with automatic optimistic caching, rollback, offline queue support, and toast feedback.
Readme
@zuiio/query — Optimistic Mutation Primitives
TanStack Query v5 + React 19 mutation helpers with automatic optimistic caching, rollback, offline queue support, and toast feedback.
Quick Install
bun add @zuiio/query @tanstack/react-query
# peer: react ^19, sonner ^2
# internal peers: @zuiio/hooks, @zuiio/offlineWrap your app root with the provider:
import { QueryProvider } from "@zuiio/query";
export default function RootLayout({ children }) {
return <QueryProvider>{children}</QueryProvider>;
}Optimistic Hooks
Two families — FormData (for Server Action / createClientAction patterns) and JSON (for fetch / JSON payloads). Both snapshot the cache before mutation and restore on error.
"use client";
import {
useOptimisticCreate,
useOptimisticUpdate,
useOptimisticDelete,
} from "@zuiio/query";
const serviceKeys = {
all: (orgId: string) => ["services", orgId] as const,
list: (orgId: string) => ["services", orgId, "list"] as const,
};
function ServiceList({ orgId }: { orgId: string }) {
// Create — prepend to list instantly
const create = useOptimisticCreate<Service>({
mutationFn: async (fd) => createService(fd),
queryKey: () => serviceKeys.list(orgId),
buildOptimistic: (fd) => ({
id: crypto.randomUUID(),
name: fd.get("name") as string,
}),
});
// Update — merge partial into matching item
const update = useOptimisticUpdate<Service>({
mutationFn: async (fd) => updateService(fd),
queryKey: () => serviceKeys.list(orgId),
getId: (fd) => fd.get("id") as string,
buildOptimistic: (fd) => ({
name: fd.get("name") as string,
}),
});
// Delete — remove from list instantly
const del = useOptimisticDelete({
mutationFn: async (fd) => deleteService(fd),
queryKey: () => serviceKeys.list(orgId),
getId: (fd) => fd.get("id") as string,
});
return (
<form onSubmit={(e) => {
const fd = new FormData(e.currentTarget);
create.mutate(fd, { onSuccess: () => e.currentTarget.reset() });
}}>
{/* ... */}
</form>
);
}JSON variants (useOptimisticCreateJson, useOptimisticUpdateJson, useOptimisticDeleteJson) mirror the same API but accept typed variables instead of FormData:
const create = useOptimisticCreateJson<Service>({
mutationFn: async (vars) => api.createService(vars),
queryKey: () => serviceKeys.list(orgId),
buildOptimistic: (vars) => ({
id: crypto.randomUUID(),
...vars,
}),
});
// call: create.mutate({ name: "Oil Change" })Org Mutation Factory
useOrgMutation replaces ~40 lines of boilerplate per mutation. One call handles: fetch + offline queue + optimistic cache + rollback + toast + invalidation.
import { useOrgMutation } from "@zuiio/query";
type Service = { id: string; name: string; price: number };
const createService = useOrgMutation<Service, { name: string; price: number }>({
endpoint: `/api/orgs/${orgId}/services`,
method: "POST",
queryKey: serviceKeys.all(orgId),
// Optimistic: append temp item with stable UUID
optimisticUpdate: (cached, vars, ctx) => [
...cached,
{ id: ctx.optimisticId, ...vars, created_at: ctx.submittedAt },
],
successMessage: "Service created",
});
// Usage
createService.mutate({ name: "Oil Change", price: 50000 });When offline, mutations are queued and replay automatically when connectivity returns. Check result with isQueuedResult(data).
import { useOrgMutation, isQueuedResult } from "@zuiio/query";
const result = await createService.mutateAsync(vars);
if (isQueuedResult(result)) {
toast.info("Saved offline — will sync when connected");
}API Reference
QueryProvider
| Prop | Type | Description |
|---|---|---|
| children | ReactNode | App subtree |
Creates a QueryClient with staleTime: 30s, gcTime: 5min. Client lives in a useRef to survive re-renders.
useOptimisticCreate / useOptimisticCreateJson
| Parameter | Type | Description |
|---|---|---|
| mutationFn | (formData: FormData \| TVariables) => Promise<unknown> | Server action or fetch call |
| queryKey | () => readonly string[] | Cache key factory (function) |
| buildOptimistic | (formData \| vars) => TItem | Returns temp item to prepend |
Returns UseMutationResult. Call via mutate(data, { onSuccess }).
useOptimisticUpdate / useOptimisticUpdateJson
| Parameter | Type | Description |
|---|---|---|
| mutationFn | (formData: FormData \| TVariables) => Promise<unknown> | Server action or fetch call |
| queryKey | () => readonly string[] | Cache key factory (function) |
| getId | (formData \| vars) => string | Extract item ID to match |
| buildOptimistic | (formData \| vars) => Partial<TItem> | Partial to merge into cached item |
Returns UseMutationResult.
useOptimisticDelete / useOptimisticDeleteJson
| Parameter | Type | Description |
|---|---|---|
| mutationFn | (formData: FormData \| TVariables) => Promise<unknown> | Server action or fetch call |
| queryKey | () => readonly string[] | Cache key factory (function) |
| getId | (formData \| vars) => string | Extract item ID to remove |
Returns UseMutationResult. Matches both id and productId fields.
useOrgMutation / useOfflineJsonMutation
useOfflineJsonMutation is an alias — use it when the context is shared offline-first JSON mutations.
| Config field | Type | Default | Description |
|---|---|---|---|
| endpoint | string \| (vars) => string | — | API URL, static or dynamic |
| method | "POST" \| "PATCH" \| "DELETE" | "POST" | HTTP method |
| queryKey | readonly unknown[] | — | Invalidation prefix |
| optimisticQueryKey | readonly unknown[] | queryKey | Exact cache key for read/write |
| mutationKey | readonly unknown[] | ["org-mutation", ...queryKey] | Dedup key for sibling mutations |
| optimisticUpdate | (cached, vars, ctx) => TCache[] | — | Transform cache before server responds |
| optimisticMode | "exact" \| "prefix" | "exact" | Single cache entry vs. all matching prefix |
| successMessage | string \| (vars) => string | — | Toast on success |
| errorMessage | string | error.message | Toast on failure |
| onErrorToast | (error, vars, { retry }) => void | — | Custom error toast with retry |
| invalidateKeys | readonly (readonly unknown[])[] | [] | Extra keys to invalidate on settle |
| bodyFn | (vars) => unknown | POST/PATCH → vars | Custom request body |
| idempotencyKey | (vars) => string | — | Idempotency-Key header factory |
| onSuccess | (data, vars) => void | — | Side effect after online success |
| onError | (error, vars) => void | — | Side effect after rollback |
Returns UseMutationResult<TResponse, Error, TVariables>.
Helpers
| Export | Description |
|---|---|
| isQueuedResult(data) | Type guard — true if mutation was queued for offline replay |
| QUEUED_SENTINEL | Symbol key used internally by QueuedResult |
Type Exports
| Type | Description |
|---|---|
| OrgMutationConfig<TResponse, TVariables, TCache> | Full config shape for useOrgMutation |
| OptimisticContext | { optimisticId: string; submittedAt: string } — passed to optimisticUpdate |
| OptimisticCreateConfig | Config shape for useOptimisticCreate |
| OptimisticUpdateConfig | Config shape for useOptimisticUpdate |
| OptimisticDeleteConfig | Config shape for useOptimisticDelete |
| OptimisticCreateJsonConfig | Config shape for JSON variant of create |
| OptimisticUpdateJsonConfig | Config shape for JSON variant of update |
| OptimisticDeleteJsonConfig | Config shape for JSON variant of delete |
Conventions
- All hooks are
"use client". They depend on the React Query client fromQueryProvider. - Rollback is automatic — every optimistic hook snapshots the cache before mutation and restores on error.
- FormData vs. JSON: Use
useOptimisticCreate(FormData) for Server Action patterns,useOptimisticCreateJson(typed vars) forfetch-based mutations. useOrgMutationinvalidatesqueryKeyprefix on settle. UseoptimisticQueryKeywhen the exact cache key differs from the invalidation prefix.- When multiple mutations share a
queryKey, usemutationKeyto prevent one mutation's refetch from clearing another's optimistic state. - Toasts via
sonner. Configurable viasuccessMessage,errorMessage,onErrorToast. - Offline queue requires
MutationQueueProviderfrom@zuiio/offline. Queued results carry aQUEUED_SENTINELsymbol — check withisQueuedResult(). optimisticUpdatereceives anOptimisticContextwith a stableoptimisticId(UUID) andsubmittedAt(ISO timestamp) — use these instead ofDate.now()for temp IDs.
