| name | datatable-migration |
| description | Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets. |
Migrating Tables from mantine-react-table to mantine-datatable
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
Migration Candidates
Current mantine-react-table consumers that could be migrated:
Widgets (in packages/widgets/):
docker/component.tsx — Docker container table
media-server/component.tsx — Media server sessions
Admin pages (in apps/nextjs/src/app/[locale]/manage/):
tools/docker/docker-table.tsx
tools/tasks/_components/tasks-table.tsx
tools/api/components/api-keys.tsx
users/_components/user-list.tsx
users/invites/_components/invite-list.tsx
tools/kubernetes/ — 8 Kubernetes resource tables
Modals:
packages/modals-collection/src/search-engines/request-media-modal.tsx
Shared hook to remove once all migrated:
packages/ui/src/hooks/use-translated-mantine-react-table.ts
1. Core Migration: mantine-react-table → mantine-datatable
Import changes
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
import { DataTable, useDataTableColumns } from "mantine-datatable";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
Column definition shape
const columns: MRT_ColumnDef<MyRow>[] = [
{
accessorKey: "name",
header: "Name",
size: 200,
enableSorting: true,
Cell: ({ row }) => <Text>{row.original.name}</Text>,
},
];
const columns: DataTableColumn<MyRow>[] = [
{
accessor: "name",
title: "Name",
width: 200,
sortable: true,
render: (record) => <Text>{record.name}</Text>,
},
];
Key differences:
accessorKey → accessor
header → title
size → width
Cell: ({ row }) => → render: (record) =>
enableSorting → sortable
enableColumnActions → does not exist (no column action menus)
mantineTableBodyCellProps → cellsStyle in defaultColumnProps
Table component
const table = useTranslatedMantineReactTable({ columns, data, ... });
return <MantineReactTable table={table} />;
return (
<DataTable
records={data}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
...
/>
);
Sorting is manual
mantine-datatable does NOT sort for you. You must sort the data yourself:
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
columnAccessor: "name",
direction: "asc",
});
const sortedData = useMemo(() => {
const mult = sortStatus.direction === "desc" ? -1 : 1;
return [...data].toSorted((a, b) => {
const aVal = a[sortStatus.columnAccessor];
const bVal = b[sortStatus.columnAccessor];
if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult;
return ((aVal as number) - (bVal as number)) * mult;
});
}, [data, sortStatus]);
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
2. Making Tables Transparent (Widget Context)
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to var(--mantine-color-body) which is opaque.
Create a CSS file alongside the component:
.my-table,
.my-table .mantine-datatable-table,
.my-table .mantine-datatable-table thead,
.my-table .mantine-datatable-table tbody,
.my-table .mantine-datatable-table tfoot,
.my-table th,
.my-table td {
background-color: transparent !important;
}
.my-table .mantine-datatable-table tr {
background-color: transparent;
}
.my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(
in srgb,
var(--mantine-color-default-hover) 40%,
transparent
) !important;
}
.my-table th {
white-space: nowrap;
background-color: color-mix(
in srgb,
var(--mantine-color-body) 60%,
transparent
) !important;
backdrop-filter: blur(8px);
}
{
: nowrap;
: hidden;
: ellipsis;
}
{
: ;
: opacity ;
}
{
: ;
}
{
: (
in srgb,
(--mantine-color-body) ,
transparent
) ;
}
Import it: import "./styles.css"; and apply via className="my-table".
3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives width as a prop.
interface SizeConfig {
fontSize: "xs" | "sm";
iconSize: number;
cellPadding: number;
showSpeedColumns: boolean;
showTimeColumn: boolean;
showStateColumn: boolean;
}
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{
maxWidth: 300,
config: {
fontSize: "xs",
iconSize: 12,
cellPadding: 2,
showSpeedColumns: false,
showTimeColumn: false,
showStateColumn: false,
},
},
{
maxWidth: 500,
config: {
fontSize: "xs",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: false,
showTimeColumn: true,
showStateColumn: false,
},
},
];
const DEFAULT_SIZE_CONFIG: = {
: ,
: ,
: ,
: ,
: ,
: ,
};
(): {
( { maxWidth, config } ) {
(width < maxWidth) config;
}
;
}
size = ( (width), [width]);
Then use a visibility check lookup table for columns:
const columnVisibilityChecks: Partial<
Record<string, (ctx: ColumnContext) => boolean>
> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
state: (ctx) => ctx.size.showStateColumn,
};
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
if (!ctx.optionsColumnSet.has(accessor)) return false;
const check = columnVisibilityChecks[accessor];
if (check && !check(ctx)) return false;
return true;
}
4. Column Persistence (Drag/Resize → Server)
useDataTableColumns stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
Hidden widget options
In index.ts, add hidden text options:
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
With visibility override:
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
}
Translation keys required
Even hidden options need translation keys or the translation test fails:
"widget.mywidget.option.columnOrder.label": "Column order",
"widget.mywidget.option.columnWidths.label": "Column widths"
Dynamic storeKey
Use a key that includes itemId and sorted column names. This prevents cross-widget localStorage bleeding:
const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
Hydration from server → localStorage
On mount, seed useDataTableColumns with server-persisted values. Use a hydrated ref to prevent the persist effect from immediately writing defaults back:
const {
effectiveColumns,
columnsOrder,
columnsWidth,
setColumnsOrder,
setMultipleColumnWidths,
} = useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({
accessor,
width: w,
})),
);
}
requestAnimationFrame(() => {
hydrated.current = true;
});
}, [
storeKey,
savedOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
]);
Persist localStorage → server
Watch for changes and write back:
const prevOrder = useRef(columnsOrder);
const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged =
JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged =
JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged)
persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const v = entry[key as keyof typeof entry];
( v === ) widthMap[key] = v;
( v === && v.())
widthMap[key] = (v, );
}
({ : .(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
The persistOption helper calls both setOptions (local state) and saveItemOptions (server mutation).
5. Context Menu (Right-Click)
Do NOT install mantine-contextmenu. Use Mantine's Menu + Portal directly:
interface ContextMenuState {
x: number;
y: number;
item: MyRow;
}
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const handleContextMenu = useCallback(
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
},
[],
);
<DataTable onRowContextMenu={handleContextMenu} />
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
The menu component uses Portal for correct z-index:
function RowContextMenu({
state,
onClose,
}: {
state: ContextMenuState;
onClose: () => void;
}) {
return (
<Portal>
<Menu
opened
onClose={onClose}
closeOnItemClick={false}
position="right-start"
offset={0}
>
<Menu.Target>
<Box
style={{
position: "fixed",
left: state.x,
top: state.y,
width: 0,
height: 0,
}}
/>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => {
/* action */ onClose();
}}
>
Action
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Portal>
);
}
Key fix: Use closeOnItemClick={false} if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: onScroll={() => { if (contextMenu) closeContextMenu(); }}.
6. Row Expansion
<DataTable
rowExpansion={{
trigger: "click",
allowMultiple: true,
collapseProps: {
transitionDuration: 200,
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
}}
/>
Critical: Do NOT set onRowClick when using rowExpansion with trigger: "click". They conflict and the expansion won't work.
7. Common Pitfalls & Fixes
Status indicator dots
Don't use <Badge variant="dot" /> without children — it renders a pill with empty space. Use a plain Box:
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-filled)`,
flexShrink: 0,
}}
/>
Tooltip overflow on long text columns
When a column has ellipsis: true, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
<Tooltip position="bottom-start" ...>
<Group style={{ overflow: "hidden" }}>
...
</Group>
</Tooltip>
Use position="bottom-start" instead of position="right" so the tooltip never escapes the widget horizontally.
Dark mode tooltips
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set color="dark" on informational tooltips inside widgets.
Division by zero
Guard all ratio calculations:
if (item.size > 0) ratio = item.sent / item.size;
if (totalDown > 0) ratio = totalUp / totalDown;
Composite idAccessor
When rows can come from multiple integrations, IDs may collide. Use a composite key:
idAccessor={(record) => `${record.integration.id}:${record.id}`}
JSON option parsing
Always wrap in try/catch:
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
Clipboard API
navigator.clipboard.writeText() can throw in non-HTTPS contexts. Always catch:
void navigator.clipboard.writeText(text).catch(() => {});
Sync sortStatus with widget options
If the widget has configurable default sort, sync it:
useEffect(() => {
setSortStatus({
columnAccessor: options.defaultSort,
direction: defaultSortDirection,
});
}, [options.defaultSort, defaultSortDirection]);
8. Accessibility
- All
ActionIcon components need aria-label
- Filter toggles (client badges, status badges) need
aria-pressed={isActive}
textSelectionDisabled on DataTable prevents accidental text selection during drag/resize
9. Error Handling
- Every mutation needs an
onError callback with showErrorNotification
- The
saveItemOptions mutation (column persistence) also needs onError
- Check
isError on the main query and show a fallback UI
- Use a shared
mutationOptions object to avoid repeating onSettled/onError:
const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(),
onError: () =>
showErrorNotification({
title: t("errors.actionFailed"),
message: t("errors.actionFailedMessage"),
}),
};
10. i18n
- No hardcoded strings. Every user-visible string goes through
useScopedI18n
- Column titles:
t("items.{accessor}.columnTitle")
- Detail labels:
t("items.{accessor}.detailsTitle")
- States:
t("states.{state}")
- Actions:
t("actions.{action}")
- Errors:
t("errors.{errorKey}")
- Hidden options still need
.label translation keys
11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} />
12. DataTable Props Reference (Commonly Used)
<DataTable
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
fetching={isFetching && data.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => record.uniqueKey}
height="100%"
className="my-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
rowBackgroundColor={(record) => conditionalBgMap[record.state]}
onRowContextMenu={handleContextMenu}
rowExpansion={{ ... }}
onScroll={() => { if (contextMenu) closeContextMenu(); }}
/>
13. Layout Stability
- Don't use
table-layout: fixed — it fights mantine-datatable's resize logic
- Give non-name columns explicit
width values so only the name column flexes
- Use
ellipsis: true on the name column — it handles text-overflow: ellipsis internally
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
flexShrink: 0 on badges/icons prevents them from being squeezed by long names
14. Checklist for Each Migration
- Replace imports (
mantine-react-table → mantine-datatable)
- Convert column definitions (
accessorKey → accessor, header → title, Cell → render, etc.)
- Implement manual sorting with
useState + useMemo
- Add CSS file for transparent backgrounds (if widget context)
- Add responsive column visibility (if widget context)
- Add hidden options + hydration logic (if column persistence needed)
- Add translation keys (including hidden option labels)
- Add context menu if row actions exist
- Add row expansion if detail view exists
- Add error handling on all mutations
- Add accessibility attributes
- Test at multiple widget widths
- Update docs in
apps/docs/docs/widgets/ if applicable
- Run
pnpm turbo typecheck
- Run translation spec — catches missing keys