@mehdashti/data-client
v0.4.2
Published
TanStack Query adapter with error mapping and retry logic for Smart Platform
Maintainers
Readme
@smart/data-client
TanStack Query adapter with error mapping and retry logic for Smart Platform
Installation
pnpm add @smart/data-client @tanstack/react-queryFeatures
- ✅ Type-safe Query Keys: Factory functions for consistent query key patterns
- ✅ Error Mapping: Automatic mapping from @smart/contracts ErrorResponse
- ✅ Retry Logic: Smart retry behavior (5xx, 429, network errors)
- ✅ Custom Hooks: useApiQuery, useApiMutation, usePaginatedQuery
- ✅ Query Client: Pre-configured QueryClient with platform defaults
Quick Start
1. Setup QueryClient
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@smart/data-client";
const queryClient = createQueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}2. Use in Components
import { useApiQuery, apiFetch, queryKeys } from "@smart/data-client";
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useApiQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => apiFetch(`/api/users/${userId}`),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}Query Keys
Type-safe query key factories for consistent patterns:
import { createQueryKeyFactory, queryKeys } from "@smart/data-client";
// Built-in factories
queryKeys.users.all; // ["users"]
queryKeys.users.lists(); // ["users", "list"]
queryKeys.users.list({ role: "admin" }); // ["users", "list", { role: "admin" }]
queryKeys.users.details(); // ["users", "detail"]
queryKeys.users.detail(123); // ["users", "detail", 123]
// Create custom factory
const productKeys = createQueryKeyFactory("products");
productKeys.all; // ["products"]
productKeys.list({ category: "electronics" }); // ["products", "list", { category: "electronics" }]Hooks
useApiQuery
Wrapper around useQuery with automatic error mapping:
import { useApiQuery, apiFetch } from "@smart/data-client";
const { data, error, isLoading } = useApiQuery({
queryKey: ["users", userId],
queryFn: () => apiFetch<User>(`/api/users/${userId}`),
});
// error is automatically typed as ApiError
if (error) {
console.log(error.response.detail); // ErrorResponse detail
console.log(error.status); // HTTP status code
}useApiMutation
Wrapper around useMutation with automatic error mapping:
import { useApiMutation, apiMutate } from "@smart/data-client";
import { useQueryClient } from "@tanstack/react-query";
const queryClient = useQueryClient();
const mutation = useApiMutation({
mutationFn: (data: CreateUserData) =>
apiMutate<User, CreateUserData>("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
// Use the mutation
mutation.mutate({ name: "John", email: "[email protected]" });usePaginatedQuery
Helper for paginated queries with Smart Platform pagination contract:
import { usePaginatedQuery } from "@smart/data-client";
const { data, isLoading } = usePaginatedQuery<User>({
queryKey: ["users", { page, page_size }],
endpoint: "/api/users",
params: {
page,
page_size: 20,
role: "admin",
},
});
// data.data contains the array of users
// data.meta contains pagination metadata
console.log(data.data); // User[]
console.log(data.meta); // { page, page_size, total_items, total_pages }Error Mapping
Utilities for working with API errors:
import {
isApiError,
getErrorMessage,
getFieldErrors,
getFieldError,
isValidationError,
isAuthError,
} from "@smart/data-client";
// Check error type
if (isApiError(error)) {
console.log(error.response.type); // "validation_error", "not_found", etc.
}
// Get error message
const message = getErrorMessage(error); // Works with any error type
// Handle validation errors
if (isValidationError(error)) {
const fieldErrors = getFieldErrors(error);
fieldErrors.forEach((e) => {
console.log(`${e.field}: ${e.message}`);
});
// Get specific field error
const emailError = getFieldError(error, "email");
}
// Check specific error types
if (isAuthError(error)) {
// Redirect to login
}Retry Logic
Automatic retry with exponential backoff:
- Retries: Network errors, 5xx server errors, 429 rate limit
- No retry: 4xx client errors, auth errors, validation errors
- Max retries: 3 attempts
- Backoff: 1s, 2s, 4s (exponential)
import { shouldRetry, getRetryDelay, retryConfig } from "@smart/data-client";
// Use in custom QueryClient
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: retryConfig,
},
});
// Or customize
const customClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Custom logic
if (failureCount > 5) return false;
return shouldRetry(failureCount, error);
},
retryDelay: getRetryDelay,
},
},
});Query Client Configuration
The createQueryClient function provides smart defaults:
import { createQueryClient } from "@smart/data-client";
const queryClient = createQueryClient({
// Override defaults
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes instead of 1
},
},
});Default configuration:
staleTime: 1 minutegcTime: 5 minutes (garbage collection)refetchOnWindowFocus: falseretry: Smart retry logic (5xx, 429, network)retryDelay: Exponential backoff (1s, 2s, 4s)
Examples
Fetching a List
import { useApiQuery, apiFetch, queryKeys } from "@smart/data-client";
function UserList() {
const { data, error, isLoading } = useApiQuery({
queryKey: queryKeys.users.list(),
queryFn: () => apiFetch<User[]>("/api/users"),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Creating a Resource
import { useApiMutation, apiMutate } from "@smart/data-client";
import { useQueryClient } from "@tanstack/react-query";
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useApiMutation({
mutationFn: (data: CreateUserData) =>
apiMutate<User>("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
const handleSubmit = (data: CreateUserData) => {
mutation.mutate(data);
};
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit({ name: "John", email: "[email protected]" });
}}>
{mutation.error && (
<div className="error">
{getErrorMessage(mutation.error)}
</div>
)}
<button type="submit" disabled={mutation.isPending}>
Create User
</button>
</form>
);
}Pagination with Filters
import { usePaginatedQuery } from "@smart/data-client";
import { useState } from "react";
function UserTable() {
const [page, setPage] = useState(1);
const [role, setRole] = useState<string | undefined>();
const { data, isLoading } = usePaginatedQuery<User>({
queryKey: ["users", { page, role }],
endpoint: "/api/users",
params: {
page,
page_size: 20,
role,
},
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<select onChange={(e) => setRole(e.target.value || undefined)}>
<option value="">All roles</option>
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
</select>
<table>
<tbody>
{data.data.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
<div>
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {data.meta.page} of {data.meta.total_pages}</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= data.meta.total_pages}
>
Next
</button>
</div>
</div>
);
}Correlation IDs & Request Tracing
Correlation IDs allow you to trace related requests across frontend and backend services. All API calls automatically include a correlation ID in the X-Correlation-Id header.
Basic Usage (Automatic)
By default, each API call gets a unique correlation ID:
import { apiFetch } from "@smart/data-client";
// Each call gets a unique correlation ID
await apiFetch("/api/users/123");
await apiFetch("/api/posts/456");
// ☝️ These have different correlation IDsContext-Aware Correlation (Recommended)
For better tracing, use CorrelationProvider to share correlation IDs across related requests:
import { CorrelationProvider } from "@smart/data-client";
function App() {
return (
<CorrelationProvider>
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
</CorrelationProvider>
);
}Using Correlation Scopes
Option 1: Manual Scope Control
import { useCorrelation } from "@smart/data-client";
function SaveUserButton({ userData }) {
const correlation = useCorrelation();
const handleSave = async () => {
// Begin correlation scope
correlation?.beginCorrelation();
try {
// All these requests share the same correlation ID
await createUser(userData);
await uploadAvatar(userData.avatar);
await sendWelcomeEmail(userData.email);
} finally {
// End correlation scope
correlation?.endCorrelation();
}
};
return <button onClick={handleSave}>Save User</button>;
}Option 2: Automatic Scope (Recommended)
import { useCorrelationScope } from "@smart/data-client";
function SaveUserButton({ userData }) {
const withCorrelation = useCorrelationScope();
// Automatically manages correlation scope
const handleSave = withCorrelation(async (data) => {
// All these requests share the same correlation ID
await createUser(data);
await uploadAvatar(data.avatar);
await sendWelcomeEmail(data.email);
});
return <button onClick={() => handleSave(userData)}>Save User</button>;
}Benefits of Correlation Scopes
- Better Debugging: Trace all related requests in your logs
- Error Tracking: Group related errors together
- Performance Monitoring: Measure end-to-end operation time
- Distributed Tracing: Track requests across microservices
Example: Form Submission with Correlation
import { useCorrelationScope, useApiMutation } from "@smart/data-client";
import { useSmartForm } from "@mehdashti/forms";
function CreateOrderForm() {
const withCorrelation = useCorrelationScope();
const form = useSmartForm({
schema: orderSchema,
onSubmit: withCorrelation(async (data) => {
// These requests share the same correlation ID:
const order = await createOrder(data);
await processPayment(order.id, data.payment);
await sendOrderConfirmation(order.id);
await updateInventory(data.items);
// In logs, you can trace all 4 requests together
}),
});
return <form>...</form>;
}Without CorrelationProvider
If you don't wrap your app in CorrelationProvider, the system falls back to generating a unique ID for each request (backward compatible behavior).
Request Timeout & Cancellation
Control request timeouts and cancel requests using AbortSignal.
Timeout
Set a timeout for requests that should not exceed a certain duration:
import { apiFetch } from "@smart/data-client";
// Abort after 5 seconds
const user = await apiFetch<User>("/api/users/123", {
timeout: 5000
});Manual Cancellation
Cancel requests manually using AbortController:
import { apiFetch } from "@smart/data-client";
const controller = new AbortController();
// Start request
const promise = apiFetch<User>("/api/users/123", {
signal: controller.signal
});
// Cancel it
controller.abort();
try {
await promise;
} catch (error) {
console.log("Request was aborted");
}Timeout + Manual Cancellation
Combine timeout with manual cancellation:
import { apiFetch } from "@smart/data-client";
const controller = new AbortController();
const user = await apiFetch<User>("/api/users/123", {
timeout: 10000, // Auto-abort after 10 seconds
signal: controller.signal // Also allow manual abort
});
// Can still manually abort before timeout
controller.abort();Utilities
import { createTimeoutSignal, combineAbortSignals } from "@smart/data-client";
// Create a timeout signal
const timeoutSignal = createTimeoutSignal(5000);
await apiFetch("/api/users", { signal: timeoutSignal });
// Combine multiple signals
const userSignal = new AbortController().signal;
const timeoutSignal = createTimeoutSignal(10000);
const combined = combineAbortSignals([userSignal, timeoutSignal]);
await apiFetch("/api/users", { signal: combined });With TanStack Query
TanStack Query automatically provides an AbortSignal for query cancellation:
import { useApiQuery, apiFetch } from "@smart/data-client";
const { data } = useApiQuery({
queryKey: ["users", userId],
queryFn: ({ signal }) => apiFetch(`/api/users/${userId}`, {
signal, // TanStack Query's signal
timeout: 5000 // Add timeout
})
});Type Safety
All exports are fully typed for TypeScript:
import type {
ApiError,
QueryKeyFactory,
PaginationParams,
SortParams,
FilterParams,
QueryParams,
} from "@smart/data-client";License
MIT
