Drawer Pattern

Drawer Pattern

Two patterns depending on intent.

CRUD drawers (create / edit)

URL-driven. Open state lives in search params so the back button closes the drawer, refreshes preserve state, and links are deep-linkable.

Route setup

export const Route = createFileRoute('/_app/dashboard/my-feature/')({
  validateSearch: (s: Record<string, unknown>) => ({
    drawer: s.drawer as 'create' | 'edit' | undefined,
    id: s.id as string | undefined
  }),
  loader: async ({ context }) => {
    await context.queryClient.ensureQueryData(myQueryOptions);
  },
  component: MyPage
});

Route component

Own all drawer state here. Use Route.useSearch() and useNavigate({ from: Route.fullPath }). Pass handlers as props to child components.

function MyPage() {
  const { data } = useSuspenseQuery(myQueryOptions);
  const { drawer, id } = Route.useSearch();
  const navigate = useNavigate({ from: Route.fullPath });

  const drawerOpen = drawer === 'create' || drawer === 'edit';
  // TSR requires all validateSearch keys present, pass undefined to clear
  const openCreate = () => navigate({ search: { drawer: 'create', id: undefined } });
  const openEdit = (editId: string) => navigate({ search: { drawer: 'edit', id: editId } });
  const closeDrawer = () => navigate({ search: { drawer: undefined, id: undefined } });

  return (
    <MyTable
      data={data}
      drawerOpen={drawerOpen}
      editId={id}
      onOpenCreate={openCreate}
      onOpenEdit={openEdit}
      onCloseDrawer={closeDrawer}
    />
  );
}

Table / list component

Receive state as props. Derive editingRow from editId. Wire onOpenChange to close.

type Props = {
  data: MyRecord[];
  drawerOpen: boolean;
  editId: string | undefined;
  onOpenCreate: () => void;
  onOpenEdit: (id: string) => void;
  onCloseDrawer: () => void;
};

export function MyTable({ data, drawerOpen, editId, onOpenCreate, onOpenEdit, onCloseDrawer }: Props) {
  const editingRow = editId ? data.find(r => r.id === editId) : undefined;

  return (
    <>
      {/* table with onOpenEdit callback in row actions */}
      <MyMutateDrawer
        currentRow={editingRow}
        open={drawerOpen}
        onOpenChange={open => { if (!open) onCloseDrawer(); }}
        onSubmit={handleSubmit}
      />
    </>
  );
}

Do not call useSearch({ from: '...' }) in child components. TSR's from path oscillates between route ID form (/_app/dashboard/...) and URL form (/dashboard/...) across route tree regenerations, causing TypeScript errors. Route.useSearch() in the route file is unambiguous.

Confirmation dialogs (destructive actions)

Component state. These are transient and have no URL identity.

const [deleteOpen, setDeleteOpen] = useState(false);
const [deletingRow, setDeletingRow] = useState<MyRecord | undefined>();

const handleDeleteRequest = (row: MyRecord) => {
  setDeletingRow(row);
  setDeleteOpen(true);
};

Reference implementation

src/features/feature-requests/components/feature-requests-table.tsx and src/routes/_app.dashboard.feature-requests.index.tsx.