Skip to content
Form, Please

Resource state

Show the state of a request in the form. While countries load, the country field can show a loading message and stay disabled. When the request fails, the field can show the error.

Show a request in a field

Put the request state in the form context as a ResourceState. Then read it in the field properties.

type ProfileContext = {
	readonly countries: ResourceState<readonly Country[], Error>
}
 
const profileKit = nativeFormKit.forContext<ProfileContext>()
 
const profileDefinition = profileKit.defineForm(profileSchema, (ui) => [
	ui.field("country", {
		control: "select",
		label: "Country",
		props: { emptyOption: { label: "Select a country" } },
		options: ({ context }) =>
			matchResource(context.countries, {
				pending: () => [],
				success: ({ value }) => value,
				error: () => [],
			}),
		disabled: (_values, { context }) => context.countries.status !== "success",
		description: (_values, { context }) =>
			matchResource(context.countries, {
				pending: () => "Loading countries…",
				success: ({ value }) => `${value.length} countries available`,
				error: ({ error }) => `Cannot load countries: ${error.message}`,
			}),
	}),
])

Select a request state. The options, description, and disabled state change together.

Live preview

Select a request state. The options, description, and disabled state change together.

Loading countries…

matchResource requires one callback for each state. TypeScript reports an error when a state has no callback.

Connect a request library

Your request library keeps ownership of requests, retries, cancellation, and caching. Convert its result to a ResourceState and pass it as context:

function toResource<Value>(
	query: UseQueryResult<Value, Error>,
): ResourceState<Value, Error> {
	if (query.isPending) return { status: "pending" }
	if (query.isError) return { status: "error", error: query.error }
	return { status: "success", value: query.data }
}
 
function ProfileForm() {
	const countries = useQuery({
		queryKey: ["countries"],
		queryFn: loadCountries,
	})
	const form = profileKit.useForm(profileDefinition, {
		defaultValues: { country: undefined },
		context: { countries: toResource(countries) },
	})
 
	return <profileKit.AutoForm form={form} />
}

When the query changes, the component supplies a new context. The field properties then resolve again with the current state.

This approach has these advantages:

  • The form definition does not load data. You can test it with a fixed context.
  • The request library stays the only owner of requests and cache.
  • One request state controls several field properties.

Advanced use

Keep saved options while a request runs

A pending or failed request can show saved options instead of an empty list:

type SavedCountriesContext = ProfileContext & {
	readonly savedCountries: readonly Country[]
}
 
const savedCountriesKit = nativeFormKit.forContext<SavedCountriesContext>()
 
const savedCountriesDefinition = savedCountriesKit.defineForm(
	profileSchema,
	(ui) => [
		ui.field("country", {
			control: "select",
			label: "Country",
			props: { emptyOption: { label: "Select a country" } },
			options: ({ context }) =>
				matchResource(context.countries, {
					pending: () => context.savedCountries,
					success: ({ value }) => value,
					error: () => context.savedCountries,
				}),
		}),
	],
)

Keep data after a failed background refresh

The toResource function above maps a failed background refresh to error. The field then loses the loaded options. This adapter keeps the loaded data and reports the refresh state separately:

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 queryToResource in place of toResource. 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. The UI can keep the 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 keep these extra properties when they narrow a branch.

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

Share one selector across properties

fromResource(select, cases) creates a UI resolver. It calls select with the form values and resolver details, then runs the callback for the current state. Use it when several properties read the same resource:

const selectCountries = (
	_values: unknown,
	{ context }: { readonly context: ProfileContext },
) => context.countries
 
const sharedSelectorDefinition = profileKit.defineForm(profileSchema, (ui) => [
	ui.field("country", {
		control: "select",
		label: "Country",
		props: { emptyOption: { label: "Select a country" } },
		options: ({ context }) =>
			matchResource(context.countries, {
				pending: () => [],
				success: ({ value }) => value,
				error: () => [],
			}),
		disabled: fromResource(selectCountries, {
			pending: () => true,
			success: () => false,
			error: () => true,
		}),
		description: fromResource(selectCountries, {
			pending: () => "Loading countries…",
			success: ({ value }) => `${value.length} countries available`,
			error: ({ error }) => `Cannot load countries: ${error.message}`,
		}),
	}),
])

Each fromResource callback 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.

Reference

ResourceState

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 }

The status property selects the branch. A successful state must contain value. An error state must contain error. Set the Error type when a callback reads a known error property. The default error type is unknown.

Helpers

Required resultHelper
Derive a value from a resource nowmatchResource
Create a synchronous UI resolver from a selectorfromResource
Load or refresh remote dataApplication code or a request library
Load one selectable list without request UIAsync field options

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.

UI resolvers are synchronous. The selector and all callbacks 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.

Invalid states and callback 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 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.

Next steps