BMS Auto-Save Developer Guide¶
1. Overview¶
The BMS uses auto-save on all edit pages. When a user changes a field, the value is persisted to Supabase automatically with a 500ms debounce. There are no save buttons.
When to use auto-save: Edit pages for existing records.
When NOT to use auto-save: Create pages. These use the silent-insert pattern — insert a blank row into Supabase, then navigate to the edit page where auto-save takes over.
For the full technical reference (architecture, data flow, design decisions), see the BMS Auto-Save System reference doc (in the mcc-systems repo at docs/live/bms-auto-save-system.md).
2. Minimal Example¶
A complete auto-save edit page for a widgets table with name and description columns:
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FormProvider, useForm } from 'react-hook-form';
import { supabaseClient } from '@/lib/supabase';
import { TextInput } from '@/components/text-input';
import { PageEditHeader } from '@/components/page-edit-header';
import { SectionBox } from '@/components/section-box';
import { FieldGrid } from '@/components/field-grid';
import { AutoSaveIndicator } from '@/components/auto-save-indicator';
import { FieldAutoSaver } from '@/components/FieldAutoSaver';
import { useAutoSave } from '@/hooks/useAutoSave';
const WIDGET_FIELDS = ['name', 'description'];
export const WidgetEdit = () => {
const { id } = useParams<{ id: string }>();
const { data: record } = useQuery({
queryKey: ['widget', id],
queryFn: async () => {
const { data, error } = await supabaseClient
.from('widgets')
.select('*')
.eq('id', id!)
.single();
if (error) throw error;
return data;
},
enabled: !!id,
});
const form = useForm({ defaultValues: record ?? {} });
useEffect(() => {
if (record) form.reset(record);
}, [record, form]);
const { saveField, status, error } = useAutoSave('widgets', id);
if (!record) return null;
return (
<FormProvider {...form}>
<div className="flex flex-col gap-8 w-full max-w-none">
<FieldAutoSaver fields={WIDGET_FIELDS} saveField={saveField} />
<PageEditHeader
resource="widgets"
resourceLabel="Widgets"
title={record.name || 'Untitled'}
recordId={id}
autoSaveIndicator={<AutoSaveIndicator status={status} error={error} />}
/>
<SectionBox title="Details" inputStyle="all">
<FieldGrid>
<TextInput source="name" label="Name" />
<TextInput source="description" label="Description" />
</FieldGrid>
</SectionBox>
</div>
</FormProvider>
);
};
3. Step-by-Step Setup¶
Step 1: Define the fields array¶
Create a constant array listing every Supabase column that should auto-save. Field names must match column names exactly.
Only include columns the user can edit. Exclude:
- id, created_at, updated_at (managed by the database)
- Join/relation objects (e.g. organisation from a .select('*, organisation(*)'))
- Computed or virtual fields
Step 2: Set up the form¶
Use useForm with defaultValues from the query, and form.reset() when data arrives:
const form = useForm({ defaultValues: record ?? {} });
useEffect(() => {
if (record) form.reset(record);
}, [record, form]);
Step 3: Call useAutoSave¶
Pass the Supabase table name and the record ID:
Step 4: Add FieldAutoSaver inside FormProvider¶
Place FieldAutoSaver anywhere inside the FormProvider. It renders nothing — it just watches and saves.
<FormProvider {...form}>
<FieldAutoSaver fields={WIDGET_FIELDS} saveField={saveField} />
{/* ... rest of the page */}
</FormProvider>
Step 5: Add AutoSaveIndicator to the header¶
Pass the indicator as a prop to PageEditHeader:
<PageEditHeader
autoSaveIndicator={<AutoSaveIndicator status={status} error={error} />}
// ... other props
/>
4. Common Pitfalls¶
Saving on initial render¶
Symptom: The page makes a Supabase update request as soon as it loads, writing the existing values back.
Cause: FieldAutoSaver is missing the skip-first-render logic, or the fields array includes a field whose value changes between the initial useForm defaults and the first useWatch emission.
Prevention: FieldAutoSaver handles this automatically via initialRef. You do not need to do anything extra — just make sure you are using the standard FieldAutoSaver component, not a custom useWatch + useEffect combination.
Object values from joins¶
Symptom: Console warning: [useAutoSave] Object value detected for field "organisation" on "widgets". Supabase returns a 400 error.
Cause: The fields array includes a join object name. For example, the query uses .select('*, organisation(*)') and WIDGET_FIELDS includes 'organisation'.
Fix: Remove the join field from the fields array. Only include actual column names.
Missing fields in the array¶
Symptom: A field is editable in the UI but changes are lost when navigating away.
Cause: The field name is not in the fields array passed to FieldAutoSaver.
Fix: Add the Supabase column name to the fields array.
Forgetting the AutoSaveIndicator¶
Symptom: Auto-save works but the user has no visual feedback. Save errors are invisible.
Fix: Always include <AutoSaveIndicator> in the PageEditHeader. This is required on every auto-save page.
form.reset() wiping virtual fields¶
Symptom: Fields that save to a different table (e.g. organisation fields on an opportunity page) revert to empty after a refetch.
Cause: form.reset(record) overwrites all form values with the fetched record, which does not include fields from other tables.
Fix: Store cross-table field values in refs and re-apply them after form.reset(). See the Organisation Details Data Flow reference doc (in the mcc-systems repo at docs/live/org-details-data-flow.md) for the full pattern.
Field name mismatch¶
Symptom: Supabase returns an error like column "widget_name" does not exist.
Cause: The source prop on the input component and the fields array entry do not match the actual Supabase column name.
Fix: Ensure field names in the fields array, source props, and Supabase column names are all identical.
5. Advanced Patterns¶
Saving to multiple tables¶
Some edit pages save fields to more than one Supabase table. Call useAutoSave once per table, and use separate FieldAutoSaver instances:
const { saveField, status, error } = useAutoSave('opportunities', id);
const { saveField: saveOrgField, status: orgStatus } = useAutoSave('organisations', orgId);
// In JSX:
<FieldAutoSaver fields={OPPORTUNITY_FIELDS} saveField={saveField} />
<FieldAutoSaver fields={ORG_FIELDS} saveField={saveOrgField} />
For the status indicator, you may need to combine statuses if both tables have their own indicator, or show only the primary table's status.
Conditional saves¶
If a field should only save under certain conditions (e.g. only when another field has a specific value), do not include it in the FieldAutoSaver fields array. Instead, use a custom useWatch + useEffect to call saveField conditionally:
const quoteBy = useWatch({ name: 'quote_by' });
const customRate = useWatch({ name: 'custom_hourly_rate' });
useEffect(() => {
if (quoteBy === 'custom' && customRate !== undefined) {
saveField('custom_hourly_rate', customRate);
}
}, [quoteBy, customRate, saveField]);
Be careful with this pattern — you must handle the skip-first-render logic yourself.
Computed fields¶
If a field's value is derived from other fields (e.g. a total calculated from quantity and rate), do not include it in the fields array. Instead, compute and save it in the same effect that handles the source fields, or use a database trigger to compute it server-side.