In this example, we’ll show how you can create a single form component that works for both create and update forms. We will also cover the following:

  • Create a single form component that works for both create and update based on a type prop
  • Create fields that are related to each other and need to update when one of them changes
  • Automatically handle relations and link pickers. The library automatically links/unlinks relations when a relational field is updated.
  • Debouncing/throttling fields by a specific time
  • Showing a sync indicator while updates are pending due to debouncing/throttling
  • How to pass typesafe field data to child components

The following component can be used as a create or update form just by changing the type prop.

Example

const getItemQuery = (id: string) => ({
	items: {
		room: {},
		owner: { room: {} }, $: { where: { id } },
	},
} satisfies InstaQLParams<AppSchema>);

export interface ReusableFormComponentProps {
	type: IDBFormType
}

function ItemForm({ type }: ReusableFormComponentProps) {
	const id = useRouteId();
	const navigate = useNavigate();

	const itemForm = useIDBForm({
		idbOptions: {
			type: type,
			schema: schema,
			db: db,
			entity: 'items',

			query: getItemQuery(id),
			// You can also write your query directly here
			// query: {
			// 	items: {
			// 		room: {},
			// 		owner: { room: {} }, $: { where: { id } },
			// 	},
			// } satisfies InstaQLParams<AppSchema>,

			// Optional. Prioritizes custom overrides (here) -> zod defaults -> instant defaults
			defaultValues: {
				name: '',
				shareable: true,
				category: ITEM_CATEGORY.Other,
			},
			// You can use throttleDebounceFields instead if you prefer throttling
			serverDebounceFields: {
				name: 500, // debounce name field by 500ms
			},
			// Optional: Define queries for relation fields
			linkPickerQueries: {
				// Owner picker - get list of all people and their rooms (to filter by room later)
				owner: {
					persons: {
						room: {},
						$: { order: { name: 'asc' } },
					},
				},
				// Room picker - get list of all rooms
				room: {
					rooms: {
						items: {},
						$: { order: { name: 'asc' } },
					},
				},
			},
		},
		tanstackOptions: ({ handleIDBUpdate, handleIDBCreate, zodSchema }) => ({
			validators: { onChange: zodSchema },
			listeners: {
				onChange: ({ formApi, fieldApi }) => {
					// Update form only - we send the updates to InstantDB when the form is valid
					if (type !== 'update') return;
					formApi.validate('change');
					if (formApi.state.isValid) handleIDBUpdate();
				},
			},
			onSubmit: async ({ value }) => {
				try {
					// Create form only - handleIDBCreate will create the new entity for us
					if (type !== 'create') return;
					const id = await handleIDBCreate(); // create entity
					if (!id) throw new Error('Failed to create entity');
					navigate({ to: '/items/$id', params: { id }, search: { search: '' } }); // nav to new person
				} catch (error: unknown) {
					let message = 'Error submitting form';
					if (error instanceof Error && error.message) message += `: ${error.message}`;
					toast.error(message);
				}
			},
		}),
	});

	return (
		<div className="flex flex-col gap-1">
			<itemForm.Field
				name="name"
				children={field => (
					<TextInput
						// Show a sync indicator if the field is synced, or is still pending an update due to debouncing
						className={`${type === 'update' && !field.state.meta.idbSynced ? 'unsynced' : ''}`}
						error={getErrorMessageForField(field)}
						label={`Name ${type === 'update' ? (field.state.meta.idbSynced ? '(Synced)' : '(Unsynced)') : ''}`}
						value={field.state.value}
						onChange={e => field.handleChange(e.target.value)}
					/>
				)}
			/>
			<itemForm.Field
				name="shareable"
				children={field => (
					<Checkbox
						label="Shareable"
						checked={field.state.value}
						onChange={e => field.handleChange(e.target.checked)}
						my="xs"
					/>
				)}
			/>

			<itemForm.Field
				name="category"
				children={field => (
					<SearchableSelect
						error={getErrorMessageForField(field)}
						label="Category"
						value={field.state.value}
						onChange={value => field.handleChange(value as ITEM_CATEGORY)}
						data={Object.values(ITEM_CATEGORY).map(category => ({ label: category, value: category }))}
					/>
				)}
			/>
			<itemForm.Field
				name="room"
				children={(field) => {
					// `field.state.meta.idbLinkData` is automatically populated based on the linkPickerQueries
					// In this case, it refers to the list of rooms to pass to our select field
					// This example has rooms and owners connected. When a room is changed, the owner field will be cleared and the form will not be updated until the user selects new people.
					const linkData = field.state.meta.idbLinkData || [];
					return (
						<SearchableSelect
							label="Room"
							value={field.state.value?.id}
							data={linkData.map(item => ({ label: item!.name, value: item!.id }))}
							onChange={(value) => {
								itemForm.setFieldValue('owner', []);
								field.handleChange(linkData.find(item => item!.id === value)!);
							}}
						/>
					);
				}}
			/>

			{/* See the OwnerField component below on how to pass typesafe field data to child components */}
			<itemForm.Field
				name="owner"
				children={field => <OwnerField field={field} form={itemForm} />}
			/>

			<itemForm.Field
				name="date"
				children={field => (
					<DatePickerInput
						label="Purchase Date"
						value={new Date(field.state.value)}
						onChange={value => field.handleChange(value?.getTime() ?? Date.now())}
						error={getErrorMessageForField(field)}
					/>
				)}
			/>

			<SubmitButton type={type} form={itemForm} />
		</div>
	);
}

// Helper utility to extract the form data type from the query
type ItemFormDataType = IDBExtractFormDataType<AppSchema, ReturnType<typeof getItemQuery>, 'items'>;

// In some cases, you want to create reusable child components that can be used in different forms.
// You can use the `IDBExtractFieldType` and `IDBExtractFormType` utilities to extract the typesafe field and form data types from the query.
function OwnerField({ field, form }: {
	field: IDBExtractFieldType<ItemFormDataType, 'owner'>
	form: IDBExtractFormType<ItemFormDataType>
}) {
	// In this example, we're using the `useStore` hook to get the value of the room field.
	// Then, we only display owners that are connected to the selected room.
	const room = useStore(form.store, state => state.values.room);
	const disabled = !room;
	const roomId = room ? room.id : '';
	const linkData = field.state.meta.idbLinkData || [];
	const filteredLinkData = linkData.filter(person => person.room!.id === roomId);

	return (
		<MultiSelect
			disabled={disabled}
			label="Owner(s)"
			value={field.state.value?.map(item => item!.id)}
			data={filteredLinkData.map(item => ({ label: item!.name, value: item!.id }))}
			onChange={(value) => {
				field.handleChange(linkData.filter(link => value.includes(link!.id)));
			}}
			error={getErrorMessageForField(field)}
		/>
	);
}

export default ItemForm;