React
React hooks for data fetching
The @spoosh/react package provides React hooks for data fetching with full type safety.
Installation
npm install @spoosh/core @spoosh/reactSetup
import { createSpoosh } from "@spoosh/core";
import { createReactSpoosh } from "@spoosh/react";
import { cachePlugin } from "@spoosh/plugin-cache";
import { invalidationPlugin } from "@spoosh/plugin-invalidation";
import { deduplicationPlugin } from "@spoosh/plugin-deduplication";
const plugins = [
cachePlugin({ staleTime: 5000 }),
invalidationPlugin({ autoInvalidate: "all" }),
deduplicationPlugin({ read: "in-flight" }),
] as const;
const client = createSpoosh<ApiSchema, Error, typeof plugins>({
baseUrl: "/api",
plugins,
});
export const { useRead, useWrite, useInfiniteRead } = createReactSpoosh(client);Automatic Tag Generation
Spoosh automatically generates cache tags from API paths. These tags enable precise cache invalidation.
// api.users.$get() → tags: ["users"]
// api.users[1].$get() → tags: ["users", "users/1"]
// api.posts[5].comments.$get() → tags: ["posts", "posts/5", "posts/5/comments"]Tags are hierarchical. Invalidating "users" will refetch all queries under /users/*, while invalidating "users/1" only affects that specific user.
Custom Tags
Override or extend auto-generated tags:
const { data } = useRead((api) => api.users.$get(), {
tags: ["custom-users"], // overrides auto-generated tags
additionalTags: ["dashboard-data"], // adds to auto-generated tags
});Cache Invalidation
The invalidationPlugin automatically refetches stale queries after mutations.
Auto Invalidation Modes
| Mode | Description |
|---|---|
"all" | Invalidates all tags of the mutation path (default) |
"self" | Only invalidates the exact path |
"none" | No automatic invalidation |
const { trigger } = useWrite((api) => api.posts.$post);
await trigger({
body: { title: "New Post" },
autoInvalidate: "all",
});Manual Invalidation
Target specific queries to invalidate:
const { trigger } = useWrite((api) => api.posts.$post);
await trigger({
body: { title: "New Post" },
autoInvalidate: "none",
invalidate: (api) => [
api.posts.$get, // invalidates "posts"
api.users[userId].stats.$get, // invalidates "users/{userId}/stats"
],
});You can also pass tag strings directly:
await trigger({
body: { title: "New Post" },
invalidate: ["posts", "dashboard"], // invalidates all queries with these tags
});Request Deduplication
The deduplicationPlugin prevents duplicate network requests when the same query is called multiple times simultaneously.
Deduplication Modes
| Mode | Description |
|---|---|
"in-flight" | Reuse pending request if same query is in progress (default for reads) |
false | Disable deduplication |
const { data } = useRead((api) => api.users.$get(), { dedupe: "in-flight" });When multiple components call the same query simultaneously, only one network request is made:
function Header() {
const { data: user } = useRead((api) => api.me.$get());
return <div>{user?.name}</div>;
}
function Sidebar() {
const { data: user } = useRead((api) => api.me.$get());
return <nav>{user?.email}</nav>;
}useRead
Fetch data with automatic caching and refetching. The callback calls the API method.
function UserList() {
const { data, loading, error, refetch } = useRead(
(api) => api.users.$get()
);
if (loading) 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>
);
}With Query Parameters
const { data, input } = useRead(
(api) => api.users.$get({ query: { page: 1, limit: 10 } }),
{
enabled: isReady,
additionalTags: ["custom-tag"],
}
);Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether to fetch automatically |
tags | string[] | - | Override auto-generated cache tags |
additionalTags | string[] | - | Add extra cache tags |
dedupe | "in-flight" | false | "in-flight" | Request deduplication mode |
| + plugin options | - | - | Options from installed plugins |
Returns
| Property | Type | Description |
|---|---|---|
data | TData | undefined | Response data |
error | TError | undefined | Error if request failed |
loading | boolean | True during initial load |
fetching | boolean | True during any fetch |
refetch | () => Promise | Manually trigger refetch |
abort | () => void | Abort current request |
input | object | The request input (query, body, params) |
useWrite
Trigger mutations with loading and error states. The callback selects the API method (no parentheses).
function CreateUser() {
const { trigger, loading, error } = useWrite(
(api) => api.users.$post
);
const handleSubmit = async (formData: CreateUserBody) => {
const result = await trigger({ body: formData });
if (result.data) {
console.log("Created:", result.data);
}
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button disabled={loading}>
{loading ? "Creating..." : "Create User"}
</button>
</form>
);
}With Invalidation
const { trigger } = useWrite((api) => api.posts.$post);
await trigger({
body: { title: "New Post", content: "..." },
invalidate: (api) => [api.posts.$get],
});Returns
| Property | Type | Description |
|---|---|---|
trigger | (options) => Promise | Execute the mutation |
data | TData | undefined | Response data |
error | TError | undefined | Error if request failed |
loading | boolean | True while mutation is in progress |
reset | () => void | Reset state |
abort | () => void | Abort current request |
input | object | undefined | The last trigger input |
Trigger Options
| Option | Type | Description |
|---|---|---|
body | TBody | Request body |
query | TQuery | Query parameters |
params | Record<string, string | number> | Path parameters |
autoInvalidate | "all" | "self" | "none" | Auto invalidation mode |
invalidate | (api) => Selector[] | string[] | Manual invalidation targets |
dedupe | "in-flight" | false | Request deduplication mode |
useInfiniteRead
Bidirectional paginated data fetching with infinite scroll support.
function PostList() {
const {
data,
loading,
canFetchNext,
canFetchPrev,
fetchNext,
fetchPrev,
fetchingNext,
fetchingPrev,
} = useInfiniteRead(
(api) => api.posts.paginated.$get({ query: { page: 1 } }),
{
canFetchNext: ({ response }) => response?.meta.hasMore ?? false,
nextPageRequest: ({ response, request }) => ({
query: { ...request.query, page: (response?.meta.page ?? 0) + 1 },
}),
merger: (allResponses) => allResponses.flatMap((r) => r.items),
canFetchPrev: ({ response }) => (response?.meta.page ?? 1) > 1,
prevPageRequest: ({ response, request }) => ({
query: { ...request.query, page: (response?.meta.page ?? 2) - 1 },
}),
}
);
return (
<div>
{canFetchPrev && (
<button onClick={fetchPrev} disabled={fetchingPrev}>
Load Previous
</button>
)}
{data?.map((post) => <PostCard key={post.id} post={post} />)}
{canFetchNext && (
<button onClick={fetchNext} disabled={fetchingNext}>
Load More
</button>
)}
</div>
);
}Options
| Option | Type | Required | Description |
|---|---|---|---|
canFetchNext | (ctx) => boolean | Yes | Check if next page exists |
nextPageRequest | (ctx) => Partial<TRequest> | Yes | Build request for next page |
merger | (allResponses) => TItem[] | Yes | Merge all responses into items |
canFetchPrev | (ctx) => boolean | No | Check if previous page exists |
prevPageRequest | (ctx) => Partial<TRequest> | No | Build request for previous page |
enabled | boolean | No | Whether to fetch automatically |
Context Object
type Context<TData, TRequest> = {
response: TData | undefined;
allResponses: TData[];
request: TRequest;
};Returns
| Property | Type | Description |
|---|---|---|
data | TItem[] | undefined | Merged items from all responses |
allResponses | TData[] | undefined | Array of all raw responses |
loading | boolean | True during initial load |
fetching | boolean | True during any fetch |
fetchingNext | boolean | True while fetching next page |
fetchingPrev | boolean | True while fetching previous |
canFetchNext | boolean | Whether next page exists |
canFetchPrev | boolean | Whether previous page exists |
fetchNext | () => Promise<void> | Fetch the next page |
fetchPrev | () => Promise<void> | Fetch the previous page |
refetch | () => Promise<void> | Refetch all pages |
abort | () => void | Abort current request |
error | TError | undefined | Error if request failed |