Resource state
Use resource state when remote data changes labels, descriptions, permissions, or interaction state in a form.
For a simple selectable list, prefer an async field options function. Use
resource state when the application must expose pending or error state, retain
previous data, cache, retry, search, or coordinate several UI properties.
| Required result | Helper |
|---|---|
| Derive a value immediately | matchResource |
| Define a synchronous UI resolver | fromResource |
| Load or refresh remote data | Application code or a request library |
| Load one selectable list without request UI | Async field options |
Define the resource states
ResourceState<Value, Error> is a readonly tagged union with three states:
type ResourceState<Value, Error = unknown> =
| { readonly status: "pending" }
| { readonly status: "success"; readonly value: Value }
| { readonly status: "error"; readonly error: Error }Set the Error type when a branch must read a known error property. The default
error type is unknown.
const pendingCountries: CountryResource = { status: "pending" }
const loadedCountries: CountryResource = {
status: "success",
value: [
{ value: "ca", label: "Canada" },
{ value: "jp", label: "Japan" },
],
}
const failedCountries: CountryResource = {
status: "error",
error: new Error("Country service unavailable"),
}The status property selects the branch. A successful state must contain
value. An error state must contain error.
Match a standalone resource
Use matchResource when you already have a resource and need one result now.
For example, create status text outside a form definition:
function getCountryStatus(countries: CountryResource): string {
return matchResource(countries, {
pending: () => "Loading countries",
success: ({ value }) => `${value.length} countries available`,
error: ({ error }) => `Cannot load countries: ${error.message}`,
})
}All three callbacks are required. Each callback receives the narrowed state:
| Callback | Available state data |
|---|---|
pending | status |
success | status, value |
error | status, error |
The result type is the union of the three callback results. Keep the results compatible when a caller requires one specific type.
Build a UI resolver
Use fromResource for a property in a form definition. Its first argument
selects a resource from the current form values or context. Its second argument
maps each state to the required property value.
const selectCountries: UiResolver<
CountryResource,
ProfileInput,
ProfileContext
> = (_values, { context }) => context.countries
const countryDescription = fromResource(selectCountries, {
pending: () => "Loading countries",
success: ({ value }, values) =>
`${value.length} countries available for the ${values.plan} plan`,
error: ({ error }) => `Cannot load countries: ${error.message}`,
})
const countryOptions = ({ context }: { readonly context: ProfileContext }) =>
matchResource(context.countries, {
pending: () => context.savedCountryOptions,
success: ({ value }) => value,
error: () => context.savedCountryOptions,
})Each branch receives these arguments in this order:
- The narrowed resource state.
- The complete readonly form input.
- Resolver details, which contain the readonly form context.
Use only the arguments that the branch needs. In the example, the success description reads the selected plan. The options function reads saved options from context during pending and error states.
fromResource returns a normal synchronous UI resolver. The selector and all
branches must return synchronously. Load data before or beside the form. Do not
pass an async function to fromResource; field options has its own async
contract.
Pass the resource through context
Create a contextual kit. Then pass the current context to kit.useForm.
const profileDefinition = profileKit.defineForm(profileSchema, {
ui: [
{
kind: "field",
path: "plan",
control: "select",
label: "Plan",
options: [
{ value: "solo", label: "Solo" },
{ value: "team", label: "Team" },
],
},
{
kind: "field",
path: "country",
control: "select",
label: "Country",
description: countryDescription,
options: countryOptions,
disabled: fromResource(selectCountries, {
pending: () => true,
success: () => false,
error: () => true,
}),
},
],
})
function ProfileForm({ context }: { readonly context: ProfileContext }) {
const form = profileKit.useForm(profileDefinition, {
defaultValues: { plan: "solo", country: undefined },
context,
})
return <profileKit.AutoForm form={form} />
}The example uses one resource for three properties:
optionskeeps saved options until the current request succeeds.descriptionreports the current state.disabledprevents selection without usable remote data.
Form context is runtime input. When the application supplies a new context value, the generated UI resolves again with the current resource state.
Adapt TanStack Query locally
Keep the query result in application code. Convert its states before you pass the result through form context.
This adapter preserves background refresh state without adding a TanStack Query dependency to "Form, Please":
import type { } from "@tanstack/react-query"
type = "idle" | "fetching" | "paused"
type <> =
| { readonly : "idle" }
| { readonly : "pending" }
| { readonly : "paused" }
| { readonly : "error"; readonly : }
export type <, > =
| { readonly : "pending"; readonly : }
| {
readonly : "success"
readonly :
readonly : <>
}
| { readonly : "error"; readonly : }
function <>(: ): <> {
switch () {
case "idle":
return { : "idle" }
case "fetching":
return { : "pending" }
case "paused":
return { : "paused" }
}
}
export function <, >(
: <, >,
): <, > {
if (. === "pending") {
return { : "pending", : . }
}
if (. === "success") {
return {
: "success",
: .,
: <>(.),
}
}
if (.) {
return {
: "success",
: .,
: { : "error", : . },
}
}
return { : "error", : . }
}Use the adapter at the boundary between the query and the form:
function ProfileWithCountries() {
const countriesQuery = useQuery({
queryKey: ["countries"],
queryFn: loadCountries,
})
const context: ProfileContext = {
countries: queryToResource(countriesQuery),
savedCountryOptions,
}
return <ProfileForm context={context} />
}The adapter maps query states as follows:
| Query result | Resource result |
|---|---|
| Initial load | pending |
| Loaded data | success |
| Failed background refetch with old data | success with refresh.error |
| Failed request without data | error |
The background refetch error stays in the success branch because usable data still exists. This lets the UI keep saved options and report the refresh error. The top-level error branch does not contain previous data.
QueryResourceState adds fetchStatus and refresh properties to the basic
three-state shape. matchResource and fromResource preserve these extra
properties when they narrow a branch.
Handle invalid states and branch errors
TypeScript prevents unsupported status values in typed application code. At
runtime, both helpers throw a TypeError for an unsupported status. They do not
catch errors that a selector or branch callback throws.
Keep resource objects inside the supported union. Handle request failures when you create the resource. Handle UI conversion failures where you call the helper.
Preserve request ownership
- The request library owns requests, retries, cancellation, and caching.
- The application converts request results to a resource state.
- Form context carries the current resource state.
fromResourceconverts that state into a synchronous UI property.
See Creative studio policies for a live form with saved options and background refresh state.