Marigold
v18.0.0-beta.3
Marigold
v18.0.0-beta.3

Layout

App Framealpha
Admin- and Mastermarkbeta

User Input

Bulk Actionsbeta
Filteralpha
Formsbeta
Multiple Selection
Table Recordsalpha

Feedback

Async Data Loading
Feedback Messages
Loading States

Data

Fetching and Mutationsalpha
Patterns

Table Records

Help users create, correct, and maintain records in data heavy tables without losing their context.

In data heavy applications the table is where the work happens. A user scans it and fixes a stale status, adds a record a colleague requested, or types in a whole list from a spreadsheet. Each is a different task with different needs, though every one ends with a changed table.

This pattern starts from those tasks. For each, it describes the user's situation, the problems that come with it, and the interaction that solves them with the <Table> and <Drawer> components. It also covers the moments where the table's own features, such as filters and sorting, work against the user. The focus is the interaction shell around a record, meaning how the user opens, fills, and saves it.

For building the forms themselves, see the Forms pattern. For narrowing which records are shown, see the Filter pattern. For acting on many records at once instead of one at a time, see the Bulk Actions pattern.

Match the approach to the task

The right interaction follows from two questions:

  1. How much input does the task need?
  2. How much of the table does the user still need to see while doing it?

A quick correction needs almost no room but full view of its row. A new record needs a complete form, yet still benefits from the table as reference. Only rarely does a task need so much space that giving up the table is worth it.

SituationUseWhy
The user spots one wrong value, such as a stale status, a typo in a name, or an off quantityInline edit (<Table.EditableCell>)Zero context switch. The user stays in the table. Best for high frequency, low complexity corrections.
The user creates a record or revises several fields of an existing oneDrawer (<Drawer> and a form)The default. It preserves the table as reference through a split screen model, handles hidden columns gracefully, and supports progressive disclosure for longer forms.
The editing task is stepped, nested, or needs its own URL, such as a multi step wizard or a record with editable sub tablesFull page (a dedicated route)For tasks whose shape outgrows a panel, not for records that merely have many fields. It removes the table from view, so reserve it for tasks that do not lean on the table as reference. See When a full page is the right choice.

The drawer is the default for anything beyond a single field. Reach for a full page when the task is stepped, nested, or needs its own URL, and accept that it trades away the table as reference to gain space and focus.

Do not use a dialog for record creation or editing

A dialog blocks the table behind it, which is exactly the context people rely on during data entry. Dialogs also create nested scrolling once a form grows. Keep dialogs for confirmations, such as asking before a delete, and use a drawer for everything else.

Fixing a single value

A user scanning the table spots a wrong capacity, a misspelled name, or a stale status. The change takes a second. If the interface answers with a full form in a drawer or on a separate page, the cost is out of all proportion to the change, and the user loses their place in the table. Corrections like this happen often, so even a small detour adds up. Inline editing removes the detour by letting the user correct the value in the cell itself, without ever leaving the table.

Use <Table.EditableCell> for the columns that should be editable, and leave the rest as plain cells. The field prop holds the input that appears while editing, and the children hold the value shown at rest. On save, read the submitted value from the form data and update your record.

Venues

Select a cell to edit its value in place.

Name
City
Capacity
Status
Main Street Amphitheater
Laughville
500
Active
Shakytown Comedy Club
Shakytown
300
Active
Harbor Lights Hall
Portbury
850
Draft
The Old Tannery
Riverside
220
Archived
Northgate Arena
Northgate
4,200
Active
'use client';import { useState } from 'react';import {  Badge,  Description,  NumberField,  Panel,  Select,  Table,  TextField,  Title,} from '@marigold/components';interface Venue {  id: string;  name: string;  city: string;  capacity: number;  status: 'active' | 'draft' | 'archived';}const initialVenues: Venue[] = [  {    id: '1',    name: 'Main Street Amphitheater',    city: 'Laughville',    capacity: 500,    status: 'active',  },  {    id: '2',    name: 'Shakytown Comedy Club',    city: 'Shakytown',    capacity: 300,    status: 'active',  },  {    id: '3',    name: 'Harbor Lights Hall',    city: 'Portbury',    capacity: 850,    status: 'draft',  },  {    id: '4',    name: 'The Old Tannery',    city: 'Riverside',    capacity: 220,    status: 'archived',  },  {    id: '5',    name: 'Northgate Arena',    city: 'Northgate',    capacity: 4200,    status: 'active',  },];const statusLabels: Record<Venue['status'], string> = {  active: 'Active',  draft: 'Draft',  archived: 'Archived',};const statusVariants: Record<Venue['status'], 'success' | 'info' | 'default'> =  {    active: 'success',    draft: 'info',    archived: 'default',  };export default () => {  const [venues, setVenues] = useState(initialVenues);  const update = <K extends keyof Venue>(    id: string,    field: K,    e: React.FormEvent<HTMLFormElement>  ) => {    const formData = new FormData(e.currentTarget); // [!code highlight:10]    const raw = String(formData.get(field) ?? '');    // FormData values are stringly typed, so cast back to the field's type.    const value = (field === 'capacity' ? Number(raw) : raw) as Venue[K];    setVenues(prev =>      prev.map(venue =>        venue.id === id ? { ...venue, [field]: value } : venue      )    );  };  return (    <Panel aria-label="Venues">      <Panel.Header>        <Title>Venues</Title>        <Description>Select a cell to edit its value in place.</Description>      </Panel.Header>      <Panel.Content bleed>        <Table aria-label="Venues with inline editing" size="compact">          <Table.Header>            <Table.Column rowHeader>Name</Table.Column>            <Table.Column>City</Table.Column>            <Table.Column alignX="right">Capacity</Table.Column>            <Table.Column>Status</Table.Column>          </Table.Header>          <Table.Body>            {venues.map(venue => (              <Table.Row key={venue.id}>                {/* [!code highlight:14] */}                <Table.EditableCell                  onSubmit={e => update(venue.id, 'name', e)}                  field={                    <TextField                      aria-label="Name"                      name="name"                      defaultValue={venue.name}                      required                      autoFocus                    />                  }                >                  {venue.name}                </Table.EditableCell>                <Table.Cell>{venue.city}</Table.Cell>                <Table.EditableCell                  alignX="right"                  onSubmit={e => update(venue.id, 'capacity', e)}                  field={                    <NumberField                      aria-label="Capacity"                      name="capacity"                      defaultValue={venue.capacity}                      minValue={0}                      autoFocus                    />                  }                >                  {venue.capacity.toLocaleString()}                </Table.EditableCell>                <Table.EditableCell                  onSubmit={e => update(venue.id, 'status', e)}                  field={                    <Select                      aria-label="Status"                      name="status"                      defaultValue={venue.status}                      autoFocus                    >                      <Select.Option id="active">Active</Select.Option>                      <Select.Option id="draft">Draft</Select.Option>                      <Select.Option id="archived">Archived</Select.Option>                    </Select>                  }                >                  <Badge variant={statusVariants[venue.status]}>                    {statusLabels[venue.status]}                  </Badge>                </Table.EditableCell>              </Table.Row>            ))}          </Table.Body>        </Table>      </Panel.Content>    </Panel>  );};

A few rules keep inline editing safe and predictable:

  • Reserve it for single, low risk fields. A status, a name, or a quantity is a good fit. Anything with complex validation, foreign key relationships, or cascading side effects belongs in a drawer.
  • Validate inline. Keep the field constraints on the input itself, for example a required name, so an invalid value is caught before it commits and the user sees the message in place.

Creating and editing whole records

Creating a record or revising several fields needs a real form, which raises the question of what happens to the table meanwhile. While filling a form, people keep referring back to it. They check how a similar record was filled in, copy a spelling, or compare a capacity against the row above. An interaction that hides or blocks the table removes the reference material that data entry depends on.

A drawer solves this with a split screen model. It slides in from the side and holds the full form while the table stays visible and scrollable behind it. The user can read another record, copy a value across, and come back to the form without losing their place in either.

The same drawer handles both jobs. An "Add" button opens it with an empty form for creation. An "Edit" button on each row opens it prefilled with that record for editing. The title and the submit behavior follow the mode, but the form layout is identical, so users learn one screen instead of two.

Venues

Add a venue or edit an existing one.

Name
City
Type
Capacity
Status
Actions
Main Street Amphitheater
Laughville
Outdoor venue
500
Active
Shakytown Comedy Club
Shakytown
Club or lounge
300
Active
Harbor Lights Hall
Portbury
Formal venue
850
Draft
'use client';import { useRef, useState } from 'react';import {  Badge,  Button,  Checkbox,  ConfirmationDialog,  Description,  Drawer,  Form,  NumberField,  Panel,  Select,  Stack,  Table,  TextField,  Title,  ToastProvider,  useToast,} from '@marigold/components';interface Venue {  id: string;  name: string;  city: string;  type: string;  capacity: number;  status: 'active' | 'draft' | 'archived';  accessible: boolean;}const initialVenues: Venue[] = [  {    id: '1',    name: 'Main Street Amphitheater',    city: 'Laughville',    type: 'outdoor',    capacity: 500,    status: 'active',    accessible: true,  },  {    id: '2',    name: 'Shakytown Comedy Club',    city: 'Shakytown',    type: 'club',    capacity: 300,    status: 'active',    accessible: false,  },  {    id: '3',    name: 'Harbor Lights Hall',    city: 'Portbury',    type: 'formal',    capacity: 850,    status: 'draft',    accessible: true,  },];const typeLabels: Record<string, string> = {  outdoor: 'Outdoor venue',  club: 'Club or lounge',  formal: 'Formal venue',  arena: 'Arena',};const statusLabels: Record<Venue['status'], string> = {  active: 'Active',  draft: 'Draft',  archived: 'Archived',};const statusVariants: Record<Venue['status'], 'success' | 'info' | 'default'> =  {    active: 'success',    draft: 'info',    archived: 'default',  };const VenueForm = ({ venue }: { venue: Venue | null }) => (  <Stack space="regular">    <TextField      label="Name"      name="name"      defaultValue={venue?.name}      required      autoFocus    />    <TextField label="City" name="city" defaultValue={venue?.city} />    <Select label="Type" name="type" defaultValue={venue?.type ?? 'outdoor'}>      <Select.Option id="outdoor">Outdoor venue</Select.Option>      <Select.Option id="club">Club or lounge</Select.Option>      <Select.Option id="formal">Formal venue</Select.Option>      <Select.Option id="arena">Arena</Select.Option>    </Select>    <NumberField      label="Capacity"      name="capacity"      defaultValue={venue?.capacity}      minValue={0}      width="1/2"    />    <Select      label="Status"      name="status"      defaultValue={venue?.status ?? 'draft'}    >      <Select.Option id="active">Active</Select.Option>      <Select.Option id="draft">Draft</Select.Option>      <Select.Option id="archived">Archived</Select.Option>    </Select>    <Checkbox      label="Wheelchair accessible"      name="accessible"      value="true"      defaultChecked={venue?.accessible}    />  </Stack>);type VenueData = Omit<Venue, 'id'>;// Reads the editable fields out of the form. Used both to submit and to detect// unsaved edits, so the two always agree on what the record's data is.const readForm = (form: HTMLFormElement): VenueData => {  const data = new FormData(form);  return {    name: String(data.get('name') ?? ''),    city: String(data.get('city') ?? ''),    type: String(data.get('type') ?? 'outdoor'),    capacity: Number(data.get('capacity') ?? 0),    status: String(data.get('status') ?? 'draft') as Venue['status'],    accessible: data.has('accessible'),  };};// The values the form starts with, so a close attempt can tell whether the user// changed anything. Editing starts from the selected record, creating from blank.const baselineFor = (venue: Venue | null): VenueData =>  venue    ? {        name: venue.name,        city: venue.city,        type: venue.type,        capacity: venue.capacity,        status: venue.status,        accessible: venue.accessible,      }    : {        name: '',        city: '',        type: 'outdoor',        capacity: 0,        status: 'draft',        accessible: false,      };export default () => {  const { addToast } = useToast();  const [venues, setVenues] = useState(initialVenues);  const [open, setOpen] = useState(false);  const [mode, setMode] = useState<'create' | 'edit'>('create');  const [selected, setSelected] = useState<Venue | null>(null);  const nextIdRef = useRef(initialVenues.length + 1);  const formRef = useRef<HTMLFormElement>(null);  const [discardOpen, setDiscardOpen] = useState(false);  const [saving, setSaving] = useState(false);  const openCreate = () => {    // [!code highlight:11]    setMode('create');    setSelected(null);    setOpen(true);  };  const openEdit = (venue: Venue) => {    setMode('edit');    setSelected(venue);    setOpen(true);  };  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {    e.preventDefault();    const data = readForm(e.currentTarget);    // Simulate a short save so the pending and success states feel real. A real    // app would await its API call here instead of this timeout.    setSaving(true);    await new Promise(resolve => setTimeout(resolve, 400));    if (mode === 'create') {      const id = String(nextIdRef.current++);      setVenues(prev => [...prev, { id, ...data }]);      addToast({        title: 'Venue created',        description: `“${data.name}” was added to the list.`,        variant: 'success',      });    } else if (selected) {      setVenues(prev =>        prev.map(v => (v.id === selected.id ? { ...v, ...data } : v))      );      addToast({        title: 'Changes saved',        description: `“${data.name}” was updated.`,        variant: 'success',      });    }    setSaving(false);    setOpen(false);  };  const hasUnsavedChanges = () => {    const form = formRef.current;    if (!form) return false;    const current = readForm(form);    const baseline = baselineFor(selected);    return (Object.keys(baseline) as (keyof VenueData)[]).some(      key => current[key] !== baseline[key]    );  };  // Every close path (close button, Escape) funnels through here. If the form  // holds unsaved edits, intercept the close and ask before discarding them.  const requestClose = (next: boolean) => {    // [!code highlight:7]    if (!next && hasUnsavedChanges()) {      setDiscardOpen(true);      return;    }    setOpen(next);  };  return (    <Panel aria-label="Venues">      <ToastProvider position="bottom-right" />      {/* [!code highlight:12] */}      <ConfirmationDialog        open={discardOpen}        onOpenChange={setDiscardOpen}        variant="destructive"        title="Discard changes?"        confirmationLabel="Discard"        cancelLabel="Keep editing"        autoFocusButton="cancel"        onConfirm={() => setOpen(false)}      >        Your changes have not been saved. If you close the drawer now, they will        be lost.      </ConfirmationDialog>      <Panel.Header>        <Title>Venues</Title>        <Description>Add a venue or edit an existing one.</Description>        <Drawer.Trigger open={open} onOpenChange={requestClose}>          <Button variant="primary" onPress={openCreate}>            Add venue          </Button>          <Drawer size="medium" closeButton>            <Form              unstyled              ref={formRef}              key={`${mode}-${selected?.id ?? 'new'}`}              onSubmit={handleSubmit}            >              <Drawer.Title>                {/* [!code highlight] */}                {mode === 'create' ? 'Add venue' : 'Edit venue'}              </Drawer.Title>              <Drawer.Content>                <VenueForm venue={selected} />              </Drawer.Content>              <Drawer.Actions>                <Button slot="close" disabled={saving}>                  Cancel                </Button>                <Button variant="primary" type="submit" loading={saving}>                  Save                </Button>              </Drawer.Actions>            </Form>          </Drawer>        </Drawer.Trigger>      </Panel.Header>      <Panel.Content bleed>        <Table aria-label="Venues">          <Table.Header>            <Table.Column rowHeader>Name</Table.Column>            <Table.Column>City</Table.Column>            <Table.Column>Type</Table.Column>            <Table.Column alignX="right">Capacity</Table.Column>            <Table.Column>Status</Table.Column>            <Table.Column alignX="right">Actions</Table.Column>          </Table.Header>          <Table.Body>            {venues.map(venue => (              <Table.Row key={venue.id}>                <Table.Cell>{venue.name}</Table.Cell>                <Table.Cell>{venue.city}</Table.Cell>                <Table.Cell>{typeLabels[venue.type]}</Table.Cell>                <Table.Cell alignX="right">                  {venue.capacity.toLocaleString()}                </Table.Cell>                <Table.Cell>                  <Badge variant={statusVariants[venue.status]}>                    {statusLabels[venue.status]}                  </Badge>                </Table.Cell>                <Table.Cell alignX="right">                  {/* [!code highlight:7] */}                  <Button                    variant="secondary"                    size="small"                    onPress={() => openEdit(venue)}                  >                    Edit                  </Button>                </Table.Cell>              </Table.Row>            ))}          </Table.Body>        </Table>      </Panel.Content>    </Panel>  );};

Keep the experience consistent with these rules:

  • Use a medium drawer for most forms (size="medium"). It gives the form enough room while leaving the table readable. Move to a wider size only when the fields genuinely need it.
  • Show all relevant fields regardless of column visibility. The form is independent of which columns the table currently shows (see When the table hides form fields).
  • Help the user keep their bearings while editing. The table stays visible behind the drawer as a reference. Where your table supports it, highlighting the matching row is a nice touch, but it is a recommendation rather than a requirement.
  • Confirm before discarding unsaved changes. Allow dismissal through the close button, but warn the user if the form has edits that would be lost.

Layout inside the drawer

Build the form with the same building blocks as any other Marigold form. Wrap the fields in a <Form>, use <Drawer.Title> for the heading, <Drawer.Content> for the fields, and <Drawer.Actions> for the buttons. See the Forms pattern for field order, spacing, and the action hierarchy.

Entering records in a batch

Sometimes a record does not come alone. A user works through a list from an email, a stack of paper forms, or last season's data, entering one after another in a single sitting. Two sources of friction now repeat with every record. The drawer closes after each save and must be reopened, and values shared across the batch must be retyped.

A second action in the drawer, "Save and add another", removes the first friction. It commits the current record and immediately resets the form for the next one without closing the drawer. This turns record creation into a fast, repeatable loop.

Venues

Name
City
Type
Capacity

No venues yet

Add your first venue to start building the list.
'use client';import { useRef, useState } from 'react';import {  Button,  Drawer,  EmptyState,  Form,  NumberField,  Panel,  Select,  Stack,  Table,  TextField,  Title,} from '@marigold/components';interface Venue {  id: string;  name: string;  city: string;  type: string;  capacity: number;}const typeLabels: Record<string, string> = {  outdoor: 'Outdoor venue',  club: 'Club or lounge',  formal: 'Formal venue',  arena: 'Arena',};// Fields likely shared across a batch of related records. They are retained// between entries so the user does not retype them. Record specific fields// (name, capacity) are always cleared.interface Retained {  city: string;  type: string;}export default () => {  const [venues, setVenues] = useState<Venue[]>([]);  const [open, setOpen] = useState(false);  const [retained, setRetained] = useState<Retained>({    city: '',    type: 'outdoor',  });  const [formKey, setFormKey] = useState(0);  const nextIdRef = useRef(1);  const openCreate = () => {    setRetained({ city: '', type: 'outdoor' });    setFormKey(key => key + 1);    setOpen(true);  };  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {    e.preventDefault();    // Read the intent from the button that submitted the form. Deriving it from    // the event (instead of a ref set in onPress) keeps it correct even when a    // failed submit — e.g. an empty required "Name" — never reaches this handler.    const submitter = (e.nativeEvent as SubmitEvent)      .submitter as HTMLButtonElement | null;    const addAnother = submitter?.value === 'add-another';    const formData = new FormData(e.currentTarget);    const venue: Venue = {      id: String(nextIdRef.current++),      name: String(formData.get('name') ?? ''),      city: String(formData.get('city') ?? ''),      type: String(formData.get('type') ?? 'outdoor'),      capacity: Number(formData.get('capacity') ?? 0),    };    setVenues(prev => [...prev, venue]);    if (addAnother) {      // [!code highlight:7]      // Keep the drawer open, retain the contextual fields, and remount the      // form so the record specific fields clear and focus returns to "Name".      setRetained({ city: venue.city, type: venue.type });      setFormKey(key => key + 1);    } else {      setOpen(false);    }  };  return (    <Panel aria-label="Venues">      <Panel.Header>        <Title>Venues</Title>        <Drawer.Trigger open={open} onOpenChange={setOpen}>          <Button variant="primary" onPress={openCreate}>            Add venue          </Button>          <Drawer size="medium" closeButton>            <Form unstyled key={formKey} onSubmit={handleSubmit}>              <Drawer.Title>Add venue</Drawer.Title>              <Drawer.Content>                <Stack space="regular">                  <TextField label="Name" name="name" required autoFocus />                  <TextField                    label="City"                    name="city"                    defaultValue={retained.city}                  />                  <Select label="Type" name="type" defaultValue={retained.type}>                    <Select.Option id="outdoor">Outdoor venue</Select.Option>                    <Select.Option id="club">Club or lounge</Select.Option>                    <Select.Option id="formal">Formal venue</Select.Option>                    <Select.Option id="arena">Arena</Select.Option>                  </Select>                  <NumberField                    label="Capacity"                    name="capacity"                    minValue={0}                    width="1/2"                  />                </Stack>              </Drawer.Content>              <Drawer.Actions>                <Button slot="close">Cancel</Button>                {/* [!code highlight:7] */}                <Button variant="secondary" type="submit" value="add-another">                  Save & add another                </Button>                <Button variant="primary" type="submit">                  Save                </Button>              </Drawer.Actions>            </Form>          </Drawer>        </Drawer.Trigger>      </Panel.Header>      <Panel.Content bleed>        <Table aria-label="Venues">          <Table.Header>            <Table.Column rowHeader>Name</Table.Column>            <Table.Column>City</Table.Column>            <Table.Column>Type</Table.Column>            <Table.Column alignX="right">Capacity</Table.Column>          </Table.Header>          <Table.Body            items={venues}            emptyState={() => (              <EmptyState                title="No venues yet"                description="Add your first venue to start building the list."                action={<Button onPress={openCreate}>Add first venue</Button>}              />            )}          >            {venue => (              <Table.Row key={venue.id}>                <Table.Cell>{venue.name}</Table.Cell>                <Table.Cell>{venue.city}</Table.Cell>                <Table.Cell>{typeLabels[venue.type]}</Table.Cell>                <Table.Cell alignX="right">                  {venue.capacity.toLocaleString()}                </Table.Cell>              </Table.Row>            )}          </Table.Body>        </Table>      </Panel.Content>    </Panel>  );};

The loop only feels good when the details are handled with care:

  • Keep "Save" as the primary action. Style "Save and add another" as secondary so the two do not compete for visual weight.
  • Confirm each save without blocking. The new row appearing in the table behind the drawer is the clearest signal the record landed. A short toast can reinforce it, but keep it clear of the drawer's actions so it never covers the buttons during a rapid loop.
  • Return focus to the first field. After the form resets, move focus back to the top so the user can start typing the next record straight away.
  • Retain the fields that repeat. Carry contextual values into the next entry and clear only the record specific fields, as described below.

Retaining values that repeat

When a batch is related, keep the contextual fields that stay constant, such as city, category, or date, filled between entries, and clear only the record specific fields like name and capacity. When in doubt, clear everything, because a blank field is safer than a wrong default.

When a full page is the right choice

The drawer is the default for creating and editing, and it stays the default even for records with many fields. A long form is a reason to give the drawer more room and to group optional fields behind progressive disclosure, not a reason to leave the table. The table is the reference people lean on while they work, so giving it up always has a cost.

Reach for a full page (a dedicated route) when the editing task has one of these shapes, not simply when the record is large:

  • The task runs across multiple steps. A sequence of dependent steps, a wizard, or tabbed sections work poorly inside a panel. A page gives each step room and a place for a progress indicator.
  • The record contains nested records. When the form holds its own table or sub records, such as an order with editable line items, it has outgrown the panel and needs the structure of a page with sub sections.
  • The task does not lean on the table. This is the clearest signal. If the user never compares against other rows while editing, the drawer's main advantage is already gone, and a focused page serves the task better.
  • The edit needs its own URL. When the work has to be bookmarked, shared with a colleague, resumed after a refresh, or linked from elsewhere, it needs an address that an overlay cannot give it.
  • The change carries heavy downstream impact. When a save cascades into dependent records or processes and deserves a deliberate review before it commits, a page gives that review room. A drawer can already guard against lost edits, so choose a page here only when the review itself needs the space.

If none of these apply, stay with the drawer. A long form on its own does not justify the move, because the field order and progressive disclosure in When the table hides form fields absorb it without sacrificing the table. The full page is a deliberate choice for a particular kind of task, not a fallback for forms that feel big.

When a saved record disappears

Filtering, sorting, and pagination all decide which records are in view, and a freshly saved record has no special standing in that decision. It can drop out of sight the moment it is created, which to the user is indistinguishable from a failed save. They try again, create a duplicate, and stop trusting the table. The situations below show where this happens and how to prevent it.

Hidden by an active filter. When a filter is active, for example "Status is Active", and the user creates a record that does not match it, for example a draft, the record is hidden the moment it is saved. This is the filter paradox.

Address it from two directions:

  1. Inherit the active filters by default. When the drawer opens for creation, prefill the form with values that match the current filters. If the table is filtered to "Status is Active", the new record defaults to "Active", so it stays in view after saving.
  2. Warn before the record disappears. If the user changes a prefilled value to one that no longer matches the active filters, surface an inline message near the field or at the foot of the form, for example "Based on your current filters, this record will not be visible after saving." A <SectionMessage> with the warning variant fits this job.

The demo below starts filtered to active venues. Opening the form inherits that filter, so a new venue defaults to active and stays in view. Switch the status to draft to see the warning appear, then switch the filter to "All statuses" to watch the hidden record come back.

Venues

Status
Name
City
Status
Main Street Amphitheater
Laughville
Active
Shakytown Comedy Club
Shakytown
Active
'use client';import { useRef, useState } from 'react';import {  Badge,  Button,  Drawer,  Form,  Inline,  Panel,  SectionMessage,  Select,  Stack,  Table,  Text,  TextField,  Title,  ToastProvider,  useToast,} from '@marigold/components';interface Venue {  id: string;  name: string;  city: string;  status: 'active' | 'draft' | 'archived';}const initialVenues: Venue[] = [  {    id: '1',    name: 'Main Street Amphitheater',    city: 'Laughville',    status: 'active',  },  {    id: '2',    name: 'Shakytown Comedy Club',    city: 'Shakytown',    status: 'active',  },  { id: '3', name: 'Harbor Lights Hall', city: 'Portbury', status: 'draft' },];const statusLabels: Record<Venue['status'], string> = {  active: 'Active',  draft: 'Draft',  archived: 'Archived',};const statusVariants: Record<Venue['status'], 'success' | 'info' | 'default'> =  {    active: 'success',    draft: 'info',    archived: 'default',  };export default () => {  const { addToast } = useToast();  const [venues, setVenues] = useState(initialVenues);  const [filter, setFilter] = useState<'active' | 'all'>('active');  const [open, setOpen] = useState(false);  // The status is controlled so the form can warn the moment the user picks a  // value that the active filter would hide.  const [status, setStatus] = useState<Venue['status']>('active');  const [saving, setSaving] = useState(false);  const [formKey, setFormKey] = useState(0);  const nextIdRef = useRef(initialVenues.length + 1);  const visible =    filter === 'active' ? venues.filter(v => v.status === 'active') : venues;  // Inherit the active filter so a new record stays in view by default.  const openCreate = () => {    setStatus(filter === 'active' ? 'active' : 'draft'); // [!code highlight]    setFormKey(key => key + 1);    setOpen(true);  };  const willBeHidden = filter === 'active' && status !== 'active'; // [!code highlight]  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {    e.preventDefault();    const formData = new FormData(e.currentTarget);    const venue: Venue = {      id: String(nextIdRef.current++),      name: String(formData.get('name') ?? ''),      city: String(formData.get('city') ?? ''),      status,    };    // Simulate a short save so the pending and success states feel real. A real    // app would await its API call here instead of this timeout.    setSaving(true);    await new Promise(resolve => setTimeout(resolve, 400));    setVenues(prev => [...prev, venue]);    addToast({      title: 'Venue created',      description: willBeHidden        ? `“${venue.name}” was saved but is hidden by the “Active only” filter.`        : `“${venue.name}” was added to the list.`,      variant: willBeHidden ? 'warning' : 'success',    });    setSaving(false);    setOpen(false);  };  return (    <Panel aria-label="Venues">      <ToastProvider position="bottom-right" />      <Panel.Header>        <Title>Venues</Title>        <Drawer.Trigger open={open} onOpenChange={setOpen}>          <Button variant="primary" onPress={openCreate}>            Add venue          </Button>          <Drawer size="medium" closeButton>            <Form unstyled key={formKey} onSubmit={handleSubmit}>              <Drawer.Title>Add venue</Drawer.Title>              <Drawer.Content>                <Stack space="regular">                  <TextField label="Name" name="name" required autoFocus />                  <TextField label="City" name="city" />                  <Select                    label="Status"                    value={status}                    onChange={key => setStatus(key as Venue['status'])}                  >                    <Select.Option id="active">Active</Select.Option>                    <Select.Option id="draft">Draft</Select.Option>                    <Select.Option id="archived">Archived</Select.Option>                  </Select>                  {/* [!code highlight:14] */}                  {willBeHidden && (                    <SectionMessage variant="warning">                      <SectionMessage.Title>                        This venue will not be visible                      </SectionMessage.Title>                      <SectionMessage.Content>                        <Text>                          The table is filtered to active venues. Saving with                          another status hides the new venue from the current                          view.                        </Text>                      </SectionMessage.Content>                    </SectionMessage>                  )}                </Stack>              </Drawer.Content>              <Drawer.Actions>                <Button slot="close" disabled={saving}>                  Cancel                </Button>                <Button variant="primary" type="submit" loading={saving}>                  Save                </Button>              </Drawer.Actions>            </Form>          </Drawer>        </Drawer.Trigger>      </Panel.Header>      {/* The status filter is a view control — it changes which rows are shown,          not the panel's identity — so it sits in a toolbar row attached to the          table rather than in the header alongside the title and primary action. */}      <Panel.Content>        <Inline alignY="center">          <Select            label="Status"            value={filter}            onChange={key => setFilter(key as 'active' | 'all')}            width={48}          >            <Select.Option id="active">Active only</Select.Option>            <Select.Option id="all">All statuses</Select.Option>          </Select>        </Inline>      </Panel.Content>      <Panel.Content bleed>        <Table aria-label="Venues">          <Table.Header>            <Table.Column rowHeader>Name</Table.Column>            <Table.Column>City</Table.Column>            <Table.Column>Status</Table.Column>          </Table.Header>          <Table.Body            items={visible}            emptyState={() => (              <Text variant="muted">No venues match the current filter.</Text>            )}          >            {venue => (              <Table.Row key={venue.id}>                <Table.Cell>{venue.name}</Table.Cell>                <Table.Cell>{venue.city}</Table.Cell>                <Table.Cell>                  <Badge variant={statusVariants[venue.status]}>                    {statusLabels[venue.status]}                  </Badge>                </Table.Cell>              </Table.Row>            )}          </Table.Body>        </Table>      </Panel.Content>    </Panel>  );};

Two things to avoid. Do not let the record vanish silently, because that destroys trust. And do not clear all filters after a save, because that throws away the workspace the user set up. Temporarily exempting the new row from the filters (a ghost row) is another option, but it adds complexity and can confuse people who do not understand why a non matching record is showing, so it is not recommended as the default.

Displaced by sorting or pagination. If the table is sorted alphabetically and the user creates "Zebra Corp", the record belongs near the end of the data, perhaps many pages away. A user sitting on page one will not see it appear, and the save once again looks like it failed.

Confirm the save with a <Toast> such as "Zebra Corp created", and consider giving the toast an action that jumps to the record's position. Do not temporarily override the sort to force the new record to the top, because that hides the real sort state and confuses the next interaction. During a "Save and add another" loop, keep the toast brief so it does not interrupt the rhythm of entry.

When the table hides form fields

People hide table columns to focus on what matters to them, but the creation form still has to collect every field, including the ones behind hidden columns. A form that rigidly follows the database schema ignores the configuration the user just set up, while a form that only shows visible columns cannot collect a complete record.

Respect the user's configuration without losing fields:

  • Order visible fields first. Fields that map to currently visible columns belong at the top of the form, ideally in the same left to right order as the columns. The form then mirrors the table, which gives the user cognitive continuity.
  • Group hidden fields behind progressive disclosure. Fields that map to hidden columns go below, inside a collapsed <Accordion> section such as "Additional fields". This honors the user's intent while still surfacing every field.
  • Keep required fields visible. If a required field maps to a hidden column, leave it in the main section with its required indicator rather than burying it in the accordion. Only optional hidden fields belong there.

For the form layout and progressive disclosure details, see the Forms pattern.

Related

Bulk Actions

Select many records and act on all of them at once, from selection scope to partial-failure feedback.

Forms

Field order, spacing, validation, and submission for the forms inside the drawer.

Filter

Narrow the records on display and understand the filter paradox.

Table

The data display, with selection, sorting, and editable cells.
Last update: 8 days ago

Multiple Selection

Learn about how & when to use multiple selection

Async Data Loading

Learn how to implement async data loading patterns in interactive components.

On this page

Match the approach to the taskFixing a single valueCreating and editing whole recordsEntering records in a batchWhen a full page is the right choiceWhen a saved record disappearsWhen the table hides form fieldsRelated