Skip to content
Form, Please

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 resultHelper
Derive a value immediatelymatchResource
Define a synchronous UI resolverfromResource
Load or refresh remote dataApplication code or a request library
Load one selectable list without request UIAsync 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:

CallbackAvailable state data
pendingstatus
successstatus, value
errorstatus, 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:

  1. The narrowed resource state.
  2. The complete readonly form input.
  3. 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:

  • options keeps saved options until the current request succeeds.
  • description reports the current state.
  • disabled prevents 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 resultResource result
Initial loadpending
Loaded datasuccess
Failed background refetch with old datasuccess with refresh.error
Failed request without dataerror

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.
  • fromResource converts that state into a synchronous UI property.

See Creative studio policies for a live form with saved options and background refresh state.