Spoosh
Integrations

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/react

Setup

src/api/client.ts
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

ModeDescription
"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

ModeDescription
"in-flight"Reuse pending request if same query is in progress (default for reads)
falseDisable 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

OptionTypeDefaultDescription
enabledbooleantrueWhether to fetch automatically
tagsstring[]-Override auto-generated cache tags
additionalTagsstring[]-Add extra cache tags
dedupe"in-flight" | false"in-flight"Request deduplication mode
+ plugin options--Options from installed plugins

Returns

PropertyTypeDescription
dataTData | undefinedResponse data
errorTError | undefinedError if request failed
loadingbooleanTrue during initial load
fetchingbooleanTrue during any fetch
refetch() => PromiseManually trigger refetch
abort() => voidAbort current request
inputobjectThe 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

PropertyTypeDescription
trigger(options) => PromiseExecute the mutation
dataTData | undefinedResponse data
errorTError | undefinedError if request failed
loadingbooleanTrue while mutation is in progress
reset() => voidReset state
abort() => voidAbort current request
inputobject | undefinedThe last trigger input

Trigger Options

OptionTypeDescription
bodyTBodyRequest body
queryTQueryQuery parameters
paramsRecord<string, string | number>Path parameters
autoInvalidate"all" | "self" | "none"Auto invalidation mode
invalidate(api) => Selector[] | string[]Manual invalidation targets
dedupe"in-flight" | falseRequest 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

OptionTypeRequiredDescription
canFetchNext(ctx) => booleanYesCheck if next page exists
nextPageRequest(ctx) => Partial<TRequest>YesBuild request for next page
merger(allResponses) => TItem[]YesMerge all responses into items
canFetchPrev(ctx) => booleanNoCheck if previous page exists
prevPageRequest(ctx) => Partial<TRequest>NoBuild request for previous page
enabledbooleanNoWhether to fetch automatically

Context Object

type Context<TData, TRequest> = {
  response: TData | undefined;
  allResponses: TData[];
  request: TRequest;
};

Returns

PropertyTypeDescription
dataTItem[] | undefinedMerged items from all responses
allResponsesTData[] | undefinedArray of all raw responses
loadingbooleanTrue during initial load
fetchingbooleanTrue during any fetch
fetchingNextbooleanTrue while fetching next page
fetchingPrevbooleanTrue while fetching previous
canFetchNextbooleanWhether next page exists
canFetchPrevbooleanWhether previous page exists
fetchNext() => Promise<void>Fetch the next page
fetchPrev() => Promise<void>Fetch the previous page
refetch() => Promise<void>Refetch all pages
abort() => voidAbort current request
errorTError | undefinedError if request failed

On this page