> ## Documentation Index
> Fetch the complete documentation index at: https://docs.custral.com/llms.txt
> Use this file to discover all available pages before exploring further.

# React data SDK

> Read and write your workspace from a React app, with validation that follows your own schema.

**`@custral/ui`** gives a React app a provider, hooks over the [`/v1` API](/dev/api-reference/overview),
and validation derived from your workspace's own object schema. Add a property in Custral and your
forms enforce it the next time the schema loads.

It runs in the browser with a **publishable** key (`pk_…`). For server code with a secret key, use
[`@custral/sdk`](/dev/sdks/typescript). For the chat widget, use [`@custral/widget`](/dev/sdks/react).

<Note>
  **Coming from `@custral/ui@1.x`?** 1.x was the chat-widget driver, which now lives in
  [`@custral/widget`](/dev/sdks/react) and [`@custral/js`](/dev/sdks/browser). 2.0 is a different package
  under the same name. A `^1.0.0` range never installs it, so nothing changes until you ask for 2.x.
</Note>

## Install

<CodeGroup>
  ```bash npm theme={null}
  npm install @custral/ui
  ```

  ```bash pnpm theme={null}
  pnpm add @custral/ui
  ```

  ```bash bun theme={null}
  bun add @custral/ui
  ```
</CodeGroup>

React 18 or 19 is a peer dependency, so the package uses the copy your app already has.

## Quickstart

```tsx theme={null}
import {CustralProvider, useRecords} from "@custral/ui";

export function App() {
  return (
    <CustralProvider apiKey="pk_live_...">
      <Contacts />
    </CustralProvider>
  );
}

function Contacts() {
  const {records, total, isLoading, error, refresh} = useRecords("contacts", {
    limit: 25,
    sort: "created_at:desc",
    filter: {status: {neq: "archived"}},
  });

  if (isLoading) return <Spinner />;
  if (error) return <Retry onClick={refresh} />;

  return (
    <ul>
      {records.map((record) => (
        <li key={record.id}>{record.name}</li>
      ))}
      <li>{total} in total</li>
    </ul>
  );
}
```

The prop is `apiKey`, not `key`. React reserves `key`, so a provider that declared it would never
receive your credential.

## Keys

Pass a publishable key. A secret key (`sk_…`) is refused in a browser and throws, because anyone who
views the page source can read it, and rotating it is the only fix.

A publishable key can read and meter usage, and nothing else. `useCreateRecord` and `useUpdateRecord`
exist for a client holding a token your own backend minted, and a plain `pk_` is refused on them.

Check what the key can do before you render a control that needs it:

```tsx theme={null}
const {scopes, hasScope} = useMe();
if (!hasScope("records:write")) return null;
```

### A token that rotates

A `pk_` is long-lived, so `apiKey` takes a string. A short-lived credential, such as an embed frame's
`emb_…` or a bearer token your backend mints, goes in `getToken` instead. It is read fresh for every
request:

```tsx theme={null}
function Panel() {
  const [token, setToken] = useState<string | null>(null);

  // Your token source. In an embed frame this is `custral:init`, and again
  // whenever the host refreshes it.
  useEffect(() => {
    /* ...setToken(next)... */
  }, []);

  if (!token) return <Spinner />;

  return (
    <CustralProvider getToken={() => token}>
      <Records />
    </CustralProvider>
  );
}
```

* **A refreshed token keeps the cache.** Changing `apiKey` starts an empty cache on purpose. `getToken`
  does not, because a refreshed token belongs to the same viewer. An embed token expires every 15 minutes,
  and refetching everything that often is what this avoids.
* **An inline arrow is safe.** The getter is re-read on every render, so `getToken={() => token}` sends
  the newest token.
* **Keep the viewer stable.** If the token can switch to a different workspace or viewer, remount the
  provider with React's `key`. Otherwise the new viewer reads the previous one's cache.

An empty token throws rather than sending an empty `Authorization` header, so render the hooks once
you have one.

## Hooks

| Hook                          | Returns                                     | Scope            |
| ----------------------------- | ------------------------------------------- | ---------------- |
| `useMe()`                     | The key's workspace, environment and scopes | none             |
| `useObjects()`                | Every object in the workspace               | `objects:read`   |
| `useObject(idOrKey)`          | One object and its properties               | `objects:read`   |
| `useRecords(object, params)`  | One page of records                         | `records:read`   |
| `useRecord(object, id)`       | One record                                  | `records:read`   |
| `useDocument(id)`             | A document as markdown                      | `documents:read` |
| `useCreateRecord(object)`     | `mutate({fields, name})`                    | `records:write`  |
| `useUpdateRecord(object)`     | `mutate({id, fields, name})`                | `records:write`  |
| `useValidator(objectIdOrKey)` | `validate(values)` and the form's fields    | `objects:read`   |

Every read hook returns `{data, error, isLoading, isValidating, refresh}`. `useRecords` adds `records`,
`total`, `nextCursor` and `prevCursor`.

### Waiting for an id

Pass `null` and the hook sends no request and reports `isLoading: false`. A read that depends on
another waits this way:

```tsx theme={null}
const {data: contact} = useRecord("contacts", selectedId); // selectedId may be null
const {data: notes} = useDocument(contact?.fields.notes ?? null);
```

## Validation from your schema

You write no rule per field. The shape comes from `GET /v1/objects/:id`, so a property added in Custral
is enforced as soon as the schema refetches.

```tsx theme={null}
function ContactForm() {
  const {validate, fields} = useValidator("contacts");
  const [values, setValues] = useState({});
  const [errors, setErrors] = useState({});

  function submit() {
    const result = validate(values);
    setErrors(result.errors);
    if (result.valid) save(values);
  }

  return fields.map((field) => (
    <Field
      key={field.id}
      label={field.name}
      required={field.isRequired}
      error={errors[field.key]}
      onChange={(value) => setValues({...values, [field.key]: value})}
    />
  ));
}
```

`validate(values)` returns `{valid, issues, errors}`. `errors` has one message per failing key, ready to
show under a field. `issues` carries the same messages with a `code` to branch on: `required`, `type`,
`format`, `range`, `cardinality`, `option` or `unknown_property`.

<ResponseField name="partial" type="boolean">
  For an update. A required property that is absent from `values` is not reported, because you are not
  clearing it. A required property that is present and blank still fails.
</ResponseField>

<ResponseField name="allowUnknownFields" type="boolean">
  Accept keys that match no property. Off by default: the API drops a field it cannot resolve, so an
  unrecognised key is usually a typo whose data would be lost.
</ResponseField>

```tsx theme={null}
validate(values, {partial: true});
```

It checks required fields, text, email, phone, website, number and currency (including `min` and
`max`), boolean, date, time, date range order, how many values a single-value select takes, and whether
a select's value is one of its options. A property type it does not recognise passes, rather than
blocking a write the API would accept.

`validateRecord`, `validateField`, `isEmptyValue`, `writableProperties`, `resolveOption` and
`optionLabel` are exported for use outside React.

### Select options

`useObject` and `useValidator` include the `options` of each select, status and multi-select property,
so you can build the picker:

```tsx theme={null}
const {fields} = useValidator("deals");
const stage = fields.find((field) => field.key === "stage");

<select>
  {stage?.options?.map((option) => (
    <option key={option.id} value={option.id}>
      {option.value}
    </option>
  ))}
</select>;
```

A record stores the option's id, so a stage reads back as `["opt_7Fd2yL8nR1"]`. `optionLabel(property,
value)` turns that into `Qualified` for display, and falls back to the raw value when options were not
loaded. Writes accept the id or the label.

* **Absent `options`** means they were not loaded, which is what the objects list returns. Values are not
  checked.
* **Empty `options`** means the property has no choices. Every value is refused, as the API would refuse
  it.

`user`, `relation`, `file` and `image` values are never checked against options, because their ids come
from elsewhere in the workspace.

## Caching

Hooks share a small cache scoped to the provider.

* **One request per key.** Ten components asking for the same records share one request.
* **Only the newest load writes.** A slow answer for a filter you have already changed is dropped, not
  shown as the new filter's result.

`staleTime` defaults to 30 seconds: how long data is reused before a newly mounted component refetches.
Set it on the provider or per hook. `refresh()` always refetches. A successful record write invalidates
record caches so lists refetch, without blanking the rows while that happens.

Changing `apiKey` starts a fresh cache, so one workspace's records never show under another's key.

## Your own data layer

Already on TanStack Query or SWR? Skip the hooks and use the client:

```tsx theme={null}
import {CustralClient} from "@custral/ui";

const custral = new CustralClient({key: "pk_live_..."});
const page = await custral.listRecords("contacts", {limit: 25});
```

`useCustralClient()` returns the same client inside a provider. Every method throws a `CustralError`
subclass when a request is refused. The error classes are re-exported, so you can catch them without
installing `@custral/js`.

## Which package

| Package                                | For                                                     |
| -------------------------------------- | ------------------------------------------------------- |
| **`@custral/ui`**                      | React apps: hooks, caching, validation from your schema |
| [`@custral/js`](/dev/sdks/browser)     | The request layer, errors, usage metering and realtime  |
| [`@custral/widget`](/dev/sdks/react)   | The chat widget for React                               |
| [`@custral/sdk`](/dev/sdks/typescript) | Server code with a secret key                           |


## Related topics

- [React SDK](/dev/sdks/react.md)
- [SDKs](/dev/sdks/overview.md)
- [Browser SDK](/dev/sdks/browser.md)
- [TypeScript SDK](/dev/sdks/typescript.md)
- [Developer Overview](/dev/overview.md)
