Complex reusable create/update form
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:
type
propThe following component can be used as a create or update form just by changing the type
prop.
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;