spoosh-react
Spoosh React
Spoosh is a type-safe API toolkit with a composable plugin architecture for TypeScript. This skill covers the React integration including hooks API, plugins, and component patterns.
Setup
pnpm add @spoosh/core @spoosh/react
import { Spoosh } from "@spoosh/core";
import { create } from "@spoosh/react";
type ApiSchema = {
users: {
GET: { data: User[] };
POST: { data: User; body: CreateUserBody };
};
"users/:id": {
GET: { data: User };
DELETE: { data: void };
};
};
const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
cachePlugin(),
retryPlugin(),
]);
export const { useRead, useWrite, usePages, useQueue, useSSE } = create(spoosh);
createClient (Lightweight)
For simple use cases without hooks or plugins:
import { createClient } from "@spoosh/core";
const api = createClient<ApiSchema, Error>("/api");
const { data, error } = await api("users").GET();
const { data: user } = await api("users/:id").GET({ params: { id: "123" } });
await api("users").POST({ body: { name: "John" } });
Hooks API
useRead
Fetch data with automatic caching and state management.
const { data, loading, error, trigger } = useRead((api) => api("users").GET(), {
staleTime: 30000,
enabled: true,
});
Returns: data, loading, fetching, error, trigger(), abort(), meta
Options: enabled, tags, staleTime, retry, pollingInterval, refetch, debounce, transform, initialData
useWrite
Perform mutations (POST, PUT, DELETE).
const { trigger, loading, error } = useWrite((api) => api("users").POST());
// Invalidation is AUTOMATIC - no need to specify in most cases!
await trigger({ body: { name, email } });
Returns: trigger(), loading, error, data, meta, abort()
Trigger options: body, params, query, headers, invalidate, clearCache, optimistic
usePages
Bidirectional pagination with infinite scroll.
const { data, fetchNext, canFetchNext, loading } = usePages(
(api) => api("posts").GET({ query: { page: 1 } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.hasMore ?? false,
nextPageRequest: ({ lastPage, request }) => ({
query: { ...request.query, page: (lastPage?.data?.page ?? 0) + 1 },
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),
}
);
Returns: data, pages, loading, fetchingNext, canFetchNext, fetchNext(), fetchPrev(), trigger()
useQueue
Queue management for batch operations with concurrency control.
const { tasks, stats, trigger, retry } = useQueue(
(api) => api("files").POST(),
{ concurrency: 3 }
);
files.forEach((file) => trigger({ body: form({ file }) }));
Returns: tasks, stats, trigger(), abort(), retry(), remove(), removeSettled(), clear(), setConcurrency()
Stats: pending, loading, settled, success, failed, total, percentage
useSSE
Server-Sent Events for real-time streaming.
const { data, isConnected, trigger, disconnect } = useSSE(
(api) => api("stream").GET(),
{ parse: "json", accumulate: "replace" }
);
Returns: data, error, isConnected, loading, trigger(), disconnect(), reset()
Options: enabled, parse (auto|json|text|json-done), accumulate (replace|merge), maxRetries, retryDelay
Plugins
| Plugin | Purpose | Key Options |
|---|---|---|
cachePlugin |
Response caching | staleTime |
retryPlugin |
Automatic retries | retry: { retries, delay } |
pollingPlugin |
Auto-refresh | pollingInterval |
invalidationPlugin |
Cache invalidation | invalidate |
optimisticPlugin |
Instant UI updates | optimistic |
debouncePlugin |
Debounce requests | debounce |
refetchPlugin |
Refetch on focus | refetch: { onFocus, onReconnect } |
initialDataPlugin |
Preloaded data | initialData |
devtool |
Visual debugging panel | enabled, showFloatingIcon |
Devtool
import { devtool } from "@spoosh/devtool";
const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
cachePlugin(),
devtool({ enabled: process.env.NODE_ENV === "development" }),
]);
Features: request tracing, plugin visualization, cache inspector, timeline view.
Cache Invalidation
IMPORTANT: Tags and invalidation are handled automatically! You rarely need to configure them manually.
- Tags: Auto-generated from the resolved URL path (e.g.,
api("users/:id").GET({ params: { id: 123 } })→ tag:"users/123") - Invalidation: After mutations, automatically invalidates
[firstSegment, firstSegment/*]pattern
// ✅ RECOMMENDED: Let Spoosh handle it automatically
await trigger({ body: data });
// POST users/123 → auto-invalidates ["users", "users/*"]
// Only override when you need specific behavior:
await trigger({ body: data, invalidate: "posts" }); // Exact match only
await trigger({ body: data, invalidate: "posts/*" }); // Children only
await trigger({ body: data, invalidate: ["posts", "users"] }); // Multiple patterns
await trigger({ body: data, invalidate: false }); // Disable invalidation
await trigger({ body: data, invalidate: "*" }); // Global refetch all
Custom tags on queries (when auto-generated tag doesn't fit your needs):
// Custom tag only - replaces auto-generated tag
const { data } = useRead((api) => api("users").GET(), {
tags: "dashboard-users",
});
// Default path + custom tags (recommended for cross-cutting concerns)
const { data } = useRead((api) => api("stats").GET(), {
tags: ["stats", "sidebar-stats"], // Keep "stats" for auto-invalidation + add custom
});
// Then invalidate by either:
await trigger({ body: data, invalidate: "stats" }); // Path-based (auto works)
await trigger({ body: data, invalidate: "sidebar-stats" }); // Custom tag
Groups config for namespaced APIs (e.g., admin/posts, api/v1/users):
invalidationPlugin({
groups: ["admin", "api/v1"], // These prefixes use deeper segment matching
});
// POST admin/posts → invalidates ["admin/posts", "admin/posts/*"] instead of ["admin", "admin/*"]
Component Patterns
Data Fetching
export function UserList() {
const { data, loading, error, trigger } = useRead(
(api) => api("users").GET(),
{ staleTime: 30000 }
);
if (loading) return <UserListSkeleton />;
if (error) return <ErrorMessage error={error} onRetry={trigger} />;
if (!data?.length) return <EmptyState message="No users found" />;
return (
<ul>
{data.map((user) => <UserCard key={user.id} user={user} />)}
</ul>
);
}
Mutation Form
export function CreateUserForm() {
const [name, setName] = useState("");
const { trigger, loading, error } = useWrite((api) => api("users").POST());
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// No need to specify invalidate - it's automatic!
const result = await trigger({ body: { name } });
if (result.data) setName("");
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} disabled={loading} />
{error && <p className="error">{error.message}</p>}
<button disabled={loading}>{loading ? "Creating..." : "Create"}</button>
</form>
);
}
Infinite Scroll
export function InfinitePostList() {
const { ref, inView } = useInView();
const { data, loading, fetchingNext, canFetchNext, fetchNext } = usePages(
(api) => api("posts").GET({ query: { page: 1, limit: 20 } }),
{
canFetchNext: ({ lastPage }) => lastPage?.data?.hasMore ?? false,
nextPageRequest: ({ lastPage, request }) => ({
query: { ...request.query, page: (lastPage?.data?.page ?? 0) + 1 }
}),
merger: (pages) => pages.flatMap((p) => p.data?.items ?? [])
}
);
useEffect(() => {
if (inView && canFetchNext && !fetchingNext) fetchNext();
}, [inView, canFetchNext, fetchingNext]);
if (loading) return <PostListSkeleton />;
return (
<div>
{data?.map((post) => <PostCard key={post.id} post={post} />)}
<div ref={ref}>{fetchingNext && <LoadingSpinner />}</div>
</div>
);
}
Optimistic Updates
export function ToggleLikeButton({ postId, liked, likeCount }: Props) {
const { trigger } = useWrite((api) => api("posts/:id/like").POST());
const handleToggle = () => {
trigger({
params: { id: postId },
optimistic: (cache) => cache(`posts/${postId}`)
.set((current) => ({
...current,
liked: !liked,
likeCount: liked ? likeCount - 1 : likeCount + 1
}))
});
};
return <button onClick={handleToggle}>{liked ? "Unlike" : "Like"} ({likeCount})</button>;
}
Search with Debounce
export function SearchUsers() {
const [query, setQuery] = useState("");
const { data, fetching } = useRead(
(api) => api("users/search").GET({ query: { q: query } }),
{ enabled: query.length >= 2, debounce: 300 }
);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
{fetching && <LoadingIndicator />}
{data?.map((user) => <li key={user.id}>{user.name}</li>)}
</div>
);
}
Polling
export function JobStatus({ jobId }: { jobId: string }) {
const { data } = useRead(
(api) => api("jobs/:id").GET({ params: { id: jobId } }),
{
pollingInterval: ({ data }) => {
if (data?.status === "completed" || data?.status === "failed") return false;
return 2000;
}
}
);
return <p>Status: {data?.status}</p>;
}
Next.js
Server-side fetch with createClient
// app/posts/page.tsx
import { createClient } from "@spoosh/core";
const api = createClient<ApiSchema, Error>(process.env.API_URL!);
export default async function PostsPage() {
const { data: posts } = await api("posts").GET();
return <PostList initialData={posts} />;
}
Client with initialData
// components/PostList.tsx
"use client";
export function PostList({ initialData }: { initialData: Post[] }) {
const { data, loading } = useRead(
(api) => api("posts").GET(),
{ initialData } // No loading state on first render
);
return data?.map((post) => <PostCard key={post.id} post={post} />);
}
Mutation with server revalidation
// lib/spoosh.ts
import { nextjsPlugin } from "@spoosh/plugin-nextjs";
const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
cachePlugin(),
invalidationPlugin(),
nextjsPlugin(),
]);
// After mutation, Next.js cache tags are automatically revalidated
// No need to specify invalidate - it's automatic!
await trigger({ body: newPost });
Server Type Inference
Hono
import { Spoosh, StripPrefix } from "@spoosh/core";
import type { HonoToSpoosh } from "@spoosh/hono";
// Server: app.basePath("/api")
type FullSchema = HonoToSpoosh<typeof app>;
type ApiSchema = StripPrefix<FullSchema, "api">; // Avoid double /api/api
const spoosh = new Spoosh<ApiSchema, Error>("/api");
Elysia
import { Spoosh, StripPrefix } from "@spoosh/core";
import type { ElysiaToSpoosh } from "@spoosh/elysia";
// Server: new Elysia({ prefix: "/api" })
type FullSchema = ElysiaToSpoosh<typeof app>;
type ApiSchema = StripPrefix<FullSchema, "api">; // Avoid double /api/api
const spoosh = new Spoosh<ApiSchema, Error>("/api");
Use StripPrefix when your baseUrl includes the same prefix as the server's basePath to prevent double prefixing (e.g., /api/api/users).
OpenAPI
# Export TypeScript → OpenAPI
npx spoosh-openapi export --schema ./schema.ts --output openapi.json
# Import OpenAPI → TypeScript
npx spoosh-openapi import openapi.json --output ./schema.ts
References
For detailed API documentation:
references/hooks-api.md- Complete hook signaturesreferences/plugins-api.md- All plugin configurationsreferences/advanced-patterns.md- Complex patterns and edge cases
If more detail needed, fetch https://spoosh.dev/docs/react/llms (or /llms-full for complete docs).