Skip to main content
@custral/ui gives a React app a provider, hooks over the /v1 API, 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. For the chat widget, use @custral/widget.
Coming from @custral/ui@1.x? 1.x was the chat-widget driver, which now lives in @custral/widget and @custral/js. 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.

Install

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

Quickstart

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:

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:
  • 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

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:

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.
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.
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.
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.
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:
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:
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