Generic Table Abstractions
Teams often reach for a reusable table abstraction for good reasons. Several tables may repeat the same setup: headers, cells, editable controls, row actions, loading states, and toolbar behavior. Centralizing that work can look like the fastest way to improve consistency.
The trouble starts when the wrapper takes responsibility for the parts that are not the same. Let's explore where this abstraction breaks down.
The Wrapper Starts Small
The first version may do nothing more than translate rows and cells into design-system markup:
import type {TableInstance} from "@qualcomm-ui/core/table"
import {flexRender, Table} from "@qualcomm-ui/react/table"
interface DataTableProps {
table: TableInstance
}
function DataTable({table}: DataTableProps) {
return (
<Table.Root>
<Table.Table>
<Table.Body>
{table.getRowModel().rows.map((row) => (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table.Table>
</Table.Root>
)
}There is nothing inherently wrong with this component in isolation. It has one job and preserves the table instance's typed rendering context.
Feature Bloat
The next workflow needs a loading overlay. Another needs a refetch indicator. A third calls for customizable actions above the table. Each prop looks defensible in isolation. Together they reveal that several use cases are coupled to one component. Let's add a few features to see how this breaks down.
Loading Indicator
import type {ReactNode} from "react"
import type {TableInstance} from "@qualcomm-ui/core/table"
import {ProgressRing} from "@qualcomm-ui/react/progress-ring"
import {flexRender, Table} from "@qualcomm-ui/react/table"
interface DataTableProps {
isLoading?: boolean
table: TableInstance
}
function DataTable({isLoading, table}: DataTableProps) {
return (
<Table.Root>
<Table.ScrollContainer>
<Table.Table>
<Table.Body>
{isLoading ? (
<Table.Row>
<Table.Cell colSpan={table.getAllLeafColumns().length}>
<ProgressRing size="sm" />
</Table.Cell>
</Table.Row>
) : (
table.getRowModel().rows.map((row) => (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
))}
</Table.Row>
))
)}
</Table.Body>
</Table.Table>
</Table.ScrollContainer>
</Table.Root>
)
}isLoading represents the initial loading state. When there are no rows to preserve, it renders the body-level ProgressRing.
Refetch Indicator
But then a new requirement arises: we need to show a loading state while refetching. The loaded rows should remain visible while we show a separate loading indicator above the table. isRefetching adds that state to the wrapper:
interface DataTableProps {
isLoading?: boolean
isRefetching?: boolean
table: TableInstance
}
function DataTable({isLoading, isRefetching, table}: DataTableProps) {
return (
<Table.Root>
<Table.ActionBar>
{isRefetching ? <ProgressRing size="xs" /> : null}
</Table.ActionBar>
<Table.ScrollContainer>{/* ...rest of example */}</Table.ScrollContainer>
</Table.Root>
)
}DataTable now owns the presentation rules for both loading states.
Table Actions
Our designer has finished the next feature and wants to add some actions above the table. So we add another prop to the wrapper for this use case:
interface DataTableProps {
actionBar?: ReactNode
isLoading?: boolean
isRefetching?: boolean
table: TableInstance
}
function DataTable({
actionBar,
isLoading,
isRefetching,
table,
}: DataTableProps) {
return (
<Table.Root>
<Table.ActionBar>
{isRefetching ? <ProgressRing size="xs" /> : null}
{actionBar}
</Table.ActionBar>
<Table.ScrollContainer>{/* ...rest of example */}</Table.ScrollContainer>
</Table.Root>
)
}But now we've painted ourselves into a corner. The actionBar prop conflicts with the refetch indicator, which was previously positioned in the same Table.ActionBar that the new workflow needs.
At this point, DataTable is too generic in some ways and too specific in others. Adding another feature means changing the shared component or working around it. The more components that use the single abstraction, the more risk each change brings. There are better ways to manage complexity and reuse.
Avoid wrapper abstractions
Compose each table inside the component that owns the workflow. That workflow should already know whether a request is an initial load or a refetch, what a row represents, which actions the user can perform, etc.
If needed, abstract reusable concerns into separate components.
Create small, reusable pieces
TIP
Only share code when its consumers need the same behavior and would change it for the same reason.
Removing a generic application table does not mean giving up reuse. Keep the boundaries narrow:
- Use the QUI Table components like
Table.Root,Table.Header, and the other table primitives for consistent structure and styling. - Reuse typed cell components for repeated presentations and interactions.
- Reuse column modules when their accessors, headers, cells, and feature configuration change together.
- Reuse translators between table state and a specific backend contract.
Also see Reusable Columns, State, Identity, and Workflows, and Loading and Empty States.