---
title: "Call your API from React"
description: "Generate React hooks next to the typed client, then run an action with field errors, read and list with a cache, show the session and refresh on live events."
section: "guides"
status: "Planned for 0.7"
spec: "client-react"
canonical: "https://mainsdk.dev/docs/guides/use-react-hooks"
updated: "2026-10-28"
---

# Call your API from React

> Generate React hooks next to the typed client, then run an action with field errors, read and list with a cache, show the session and refresh on live events.

Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/use-react-hooks · Updated: 2026-10-28

You have a typed API client. This guide adds hooks so a component can run an action, read a record and page a list without writing `fetch`, loading state or error mapping. It takes about 15 minutes. The generated file imports only `react` and your client.

## You need

- The typed client from [Publish your actions as a typed API](https://mainsdk.dev/docs/guides/typed-api), for example `src/acme-api.ts`.
- `react` 18 or 19 in `package.json`. Without it the command exits 1 and writes nothing.

## 1. Generate the hooks

```bash
npx mainsdk api client --out src/acme-api.ts --react
```

You should see `src/acme-api-react.ts` next to the client. Use `--react-out <file>` to put it elsewhere. Run the command again after you change an action; the file is generated, so do not edit it.

## 2. Give the tree one client

Make the client once, so every component shares it.

```ts title="acme.ts"
import { createClient } from "./acme-api.ts"

export const acme = createClient({ baseUrl: "https://acme.app" })
```

```tsx
import { ApiProvider } from "./acme-api-react.ts"
import { acme } from "./acme.ts"

export function Providers({ children }: { children: React.ReactNode }) {
  return <ApiProvider client={acme}>{children}</ApiProvider>
}
```

## 3. Run an action and show field errors

```tsx
import { useAction } from "./acme-api-react.ts"
import { acme } from "./acme.ts"

export function RefundForm({ orderId, reason }: { orderId: string; reason: "damaged" | "late" }) {
  const refund = useAction(acme.orders.refund, { invalidates: ["orders.*"] })
  return (
    <form onSubmit={(e) => { e.preventDefault(); void refund.run({ orderId, reason }) }}>
      <input name="reason" />
      {refund.fieldErrors.reason && <p>{refund.fieldErrors.reason}</p>}
      <button disabled={refund.pending}>Refund</button>
    </form>
  )
}
```

`invalidates` names the reads to refetch when the action succeeds. A wrong input is a compile error, because the types come from your client.

## 4. Read and list

```tsx
import { useList, useRead } from "./acme-api-react.ts"
import { acme } from "./acme.ts"

export function Orders({ orderId }: { orderId: string }) {
  const order = useRead(acme.orders.get, { orderId }, { staleTime: "30s" })
  const orders = useList(acme.orders.list, { status: "open" }, { limit: 20 })
  return <p>{order.data?.status} {orders.items.length}</p>
}
```

`orders.items` holds every page loaded so far and `orders.more()` loads the next. Two components that read the same input share one request.

## 5. Session and live updates

```tsx
import { useLive, useSession } from "./acme-api-react.ts"

export function Header() {
  const { session, pending } = useSession()
  useLive(["order:42"], { invalidates: ["orders.get"] })
  return <p>{pending ? "…" : session ? "Signed in" : "Signed out"}</p>
}
```

`useLive` refetches the reads it names when an event arrives, and closes its subscription when the last component using it unmounts.

## 6. Use TanStack Query instead (optional)

If the app already uses TanStack Query, take the options and keep its cache.

```tsx
import { useMutation, useQuery } from "@tanstack/react-query"
import { mutationOptions, queryOptions } from "./acme-api-react.ts"
import { acme } from "./acme.ts"

export function Order({ orderId }: { orderId: string }) {
  const q = useQuery(queryOptions(acme.orders.get, { orderId }))
  const m = useMutation(mutationOptions(acme.orders.refund))
  return <button onClick={() => m.mutate({ orderId, reason: "late" })}>{q.data?.status}</button>
}
```

## Check it worked

Run the app, open a page that uses `useRead` twice with the same input, and look in the network tab: one request, not two. Submit the form with a bad reason and the field error shows without a page reload.
