npm package: @spoosh/react
Description: React hooks for Spoosh API toolkit
Latest version: 0.15.4

# @spoosh/react

React hooks for Spoosh - `useRead`, `useWrite`, `usePages`, and `useSSE`.

**[Documentation](https://spoosh.dev/docs/react)** · **Requirements:** TypeScript >= 5.0, React >= 18.0

## Installation

```bash
npm install @spoosh/core @spoosh/react
```

## Usage

### Setup

```typescript
import { Spoosh } from "@spoosh/core";
import { create } from "@spoosh/react";
import { cachePlugin } from "@spoosh/plugin-cache";

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  cachePlugin({ staleTime: 5000 }),
]);

export const { useRead, useWrite, usePages } = create(spoosh);
```

### useRead

Fetch data with automatic caching and refetching.

```typescript
function UserList() {
  const { data, loading, error, trigger } = 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 options
const { data } = useRead(
  (api) => api("users").GET({ query: { page: 1 } }),
  {
    staleTime: 10000,
    enabled: isReady,
  }
);

// With path parameters
const { data: user } = useRead(
  (api) => api("users/:id").GET({ params: { id: userId } }),
  { enabled: !!userId }
);
```

### useWrite

Trigger mutations with loading and error states.

```typescript
function CreateUser() {
  const { trigger, loading, error } = useWrite(
    (api) => api("users").POST()
  );

  const handleSubmit = async (data: CreateUserBody) => {
    const result = await trigger({ body: data });
    if (result.data) {
      // Success
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button disabled={loading}>
        {loading ? "Creating..." : "Create User"}
      </button>
    </form>
  );
}

// With path parameters
const updateUser = useWrite((api) => api("users/:id").PUT());

await updateUser.trigger({
  params: { id: userId },
  body: { name: "Updated Name" },
});
```

### usePages

Bidirectional paginated data fetching with infinite scroll support.

```typescript
function PostList() {
  const {
    data,
    pages,
    loading,
    canFetchNext,
    canFetchPrev,
    fetchNext,
    fetchPrev,
    fetchingNext,
    fetchingPrev,
  } = usePages(
    (api) => api("posts").GET({ query: { page: 1 } }),
    {
      // Required: Check if next page exists
      canFetchNext: ({ lastPage }) => lastPage?.data?.meta.hasMore ?? false,

      // Required: Build request for next page
      nextPageRequest: ({ lastPage }) => ({
        query: { page: (lastPage?.data?.meta.page ?? 0) + 1 },
      }),

      // Required: Merge all pages into items
      merger: (pages) => pages.flatMap((p) => p.data?.items ?? []),

      // Optional: Check if previous page exists
      canFetchPrev: ({ firstPage }) => (firstPage?.data?.meta.page ?? 1) > 1,

      // Optional: Build request for previous page
      prevPageRequest: ({ firstPage }) => ({
        query: { page: (firstPage?.data?.meta.page ?? 2) - 1 },
      }),
    }
  );

  return (
    <div>
      {canFetchPrev && (
        <button onClick={fetchPrev} disabled={fetchingPrev}>
          {fetchingPrev ? "Loading..." : "Load Previous"}
        </button>
      )}

      {data?.map((post) => <PostCard key={post.id} post={post} />)}

      {canFetchNext && (
        <button onClick={fetchNext} disabled={fetchingNext}>
          {fetchingNext ? "Loading..." : "Load More"}
        </button>
      )}
    </div>
  );
}
```

### useSSE

Subscribe to real-time data streams using Server-Sent Events (SSE).

```typescript
import { sse } from "@spoosh/transport-sse";

// Setup with SSE transport
const spoosh = new Spoosh<ApiSchema, Error>("/api").withTransports([sse()]);
export const { useSSE } = create(spoosh);

// Basic subscription
function Notifications() {
  const { data, isConnected, loading } = useSSE(
    (api) => api("notifications").GET({ query: { userId: "user-123" } })
  );

  if (loading) return <div>Connecting...</div>;

  return (
    <div>
      <span>{isConnected ? "Connected" : "Disconnected"}</span>
      {data?.message && <p>{data.message.text}</p>}
    </div>
  );
}

// Subscribe to specific events only
const { data } = useSSE(
  (api) => api("notifications").GET({
    query: { userId: "user-123" },
  }),
  { events: ["alert"] }  // Only alert events
);

// AI streaming with accumulation
const { data, trigger } = useSSE(
  (api) => api("chat").POST(),
  {
    events: ["chunk", "done"],
    parse: "json-done",
    accumulate: {
      chunk: (prev, curr) => ({
        ...curr,
        chunk: (prev?.chunk || "") + curr.chunk,
      }),
    },
    enabled: false,
  }
);

// Start streaming on demand
await trigger({ body: { message: "Hello" } });
```

## API Reference

### useRead(readFn, options?)

| Option           | Type      | Default | Description                          |
| ---------------- | --------- | ------- | ------------------------------------ |
| `enabled`        | `boolean` | `true`  | Whether to fetch automatically       |
| `staleTime`      | `number`  | -       | Cache stale time (from plugin-cache) |
| `retries`        | `number`  | -       | Retry attempts (from plugin-retry)   |
| + 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    |
| `trigger`  | `() => Promise`       | Manually trigger fetch   |
| `abort`    | `() => void`          | Abort current request    |

### useWrite(writeFn)

**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 |
| `abort`   | `() => void`           | Abort current request              |

### usePages(readFn, options)

| Option            | Type                         | Required | Description                                       |
| ----------------- | ---------------------------- | -------- | ------------------------------------------------- |
| `merger`          | `(pages) => TItem[]`         | Yes      | Merge all pages into items                        |
| `canFetchNext`    | `(ctx) => boolean`           | No       | Check if next page exists. Default: `() => false` |
| `nextPageRequest` | `(ctx) => Partial<TRequest>` | No       | Build request for next page                       |
| `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 passed to callbacks:**

```typescript
// For canFetchNext and nextPageRequest
type NextContext<TData, TRequest> = {
  lastPage: InfinitePage<TData> | undefined;
  pages: InfinitePage<TData>[];
  request: TRequest;
};

// For canFetchPrev and prevPageRequest
type PrevContext<TData, TRequest> = {
  firstPage: InfinitePage<TData> | undefined;
  pages: InfinitePage<TData>[];
  request: TRequest;
};

// Each page in the pages array
type InfinitePage<TData> = {
  status: "pending" | "loading" | "success" | "error" | "stale";
  data?: TData;
  error?: TError;
  meta?: TMeta;
  input?: { query?; params?; body? };
};
```

**Returns:**

| Property       | Type                          | Description                                     |
| -------------- | ----------------------------- | ----------------------------------------------- |
| `data`         | `TItem[] \| undefined`        | Merged items from all pages                     |
| `pages`        | `InfinitePage<TData>[]`       | Array of all pages with status, data, and meta  |
| `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                         |
| `trigger`      | `(options?) => Promise<void>` | Trigger fetch with optional new request options |
| `abort`        | `() => void`                  | Abort current request                           |
| `error`        | `TError \| undefined`         | Error if request failed                         |

### useSSE(subFn, options?)

| Option       | Type               | Default     | Description                       |
| ------------ | ------------------ | ----------- | --------------------------------- |
| `enabled`    | `boolean`          | `true`      | Whether to connect automatically  |
| `events`     | `string[]`         | all events  | Subscribe to specific events only |
| `parse`      | `ParseConfig`      | `"auto"`    | How to parse raw event data       |
| `accumulate` | `AccumulateConfig` | `"replace"` | How to combine events over time   |

**Returns:**

| Property      | Type                    | Description                    |
| ------------- | ----------------------- | ------------------------------ |
| `data`        | `TEvents \| undefined`  | Accumulated event data         |
| `error`       | `TError \| undefined`   | Error if connection failed     |
| `loading`     | `boolean`               | True during initial connection |
| `isConnected` | `boolean`               | True when connected to stream  |
| `trigger`     | `(options?) => Promise` | Reconnect with new options     |
| `disconnect`  | `() => void`            | Disconnect from stream         |
| `reset`       | `() => void`            | Reset accumulated data         |

**Connection Options:**

| Option        | Type                 | Description                                 |
| ------------- | -------------------- | ------------------------------------------- |
| `headers`     | `HeadersInit`        | Request headers                             |
| `credentials` | `RequestCredentials` | Credentials mode                            |
| `maxRetries`  | `number`             | Max retry attempts (default: 3)             |
| `retryDelay`  | `number`             | Delay between retries in ms (default: 1000) |