Material-Table React: The Complete Guide to Building Feature-Rich Data Tables
Every non-trivial React application eventually needs a data table — and not the decorative kind that sits there
looking pretty. You need sorting, filtering, pagination, inline editing, and ideally the ability to not rebuild
all of this from scratch every single project. That’s exactly the gap that
material-table fills.
Built on top of Material-UI components, it gives you a fully interactive, customizable React data table
with a remarkably clean API. This guide walks you through everything — from installation to production-ready
CRUD operations —
with real code you can drop into your project today.
Why material-table Is Still a Go-To React Table Component in 2025
The React ecosystem has no shortage of table libraries. You’ve got
TanStack Table (formerly React Table),
AG Grid, MUI DataGrid, and a dozen others. So why does
material-table
keep showing up in tutorials and production codebases alike? Because it hits the sweet spot between
zero-configuration out-of-the-box functionality and deep customizability
when you need it. Unlike TanStack Table, which is deliberately headless and hands you raw logic with zero UI,
material-table ships fully styled, using Material-UI’s component system. Unlike MUI DataGrid’s community tier,
which gates CRUD and advanced filtering behind a paid license, material-table is MIT-licensed and feature-complete
without a paywall.
What you get for free: sorting on any column, column-level filtering, global search, pagination with configurable
page sizes, row selection, inline editing with add/update/delete, custom actions per row,
expandable detail panels,
remote data support, and export to CSV/PDF. That’s a feature set that would take weeks to implement manually.
The library handles the UI state, the edit mode transitions, the loading indicators, and the error states —
you just supply the data and the handlers.
The one honest caveat: the original material-table package on npm hasn’t seen active maintenance
since around 2021. For new projects targeting Material-UI v5 (MUI v5), the community fork
@material-table/core is the right choice. The API is nearly identical, the migration from the
original is minimal, and it’s actively maintained. Everything in this guide applies to both —
we’ll use @material-table/core throughout.
material-table Installation and Project Setup
Getting started with
material-table installation is straightforward, but there’s a dependency chain you need to be
aware of before you run npm install and wonder why nothing renders. The library depends on
Material-UI’s core components, icons, and a peer dependency on @emotion/react and
@emotion/styled if you’re on MUI v5. Missing any of these and you’ll get either blank tables
or cryptic runtime errors.
Run the following in your React project root. This installs the community-maintained core package alongside
Material-UI, its icon set, and the required emotion styling engine:
# Using npm
npm install @material-table/core @mui/material @mui/icons-material @emotion/react @emotion/styled
# Using yarn
yarn add @material-table/core @mui/material @mui/icons-material @emotion/react @emotion/styled
If you’re working in an existing project that already uses MUI v5, you likely have @mui/material
and emotion installed. In that case, just add @material-table/core and
@mui/icons-material. The icons package is non-optional — material-table uses it internally
for the sort arrows, edit pencils, delete trash icons, and the filter funnel. Skip it and you’ll have a
functional but visually broken table.
Once installed, wrap your application (or at least the component tree that includes your table) in MUI’s
ThemeProvider if you want consistent theming. It’s not strictly required for the table to
function, but it ensures your table inherits the correct typography, color palette, and component
overrides from your app’s design system. A bare-minimum
material-table setup
needs nothing more than the import and a columns definition.
Building Your First React Data Table with material-table
The core API of this React table component
revolves around three mandatory props: columns, data, and title.
Columns define what fields to display and how to display them. Data is your array of row objects.
Title is the string rendered in the table header bar. That’s genuinely all you need to get a sorted,
searchable, paginated table on screen. Everything else is optional enhancement.
Here’s a minimal but complete
material-table example
rendering a list of users with three columns — name, email, and role. Notice the column definitions:
field maps to the property key in your data objects, title is the displayed
column header, and the optional type hint tells the table how to sort and filter that column:
import React, { useState } from 'react';
import MaterialTable from '@material-table/core';
const initialData = [
{ id: 1, name: 'Alice Nguyen', email: 'alice@example.com', role: 'Admin' },
{ id: 2, name: 'Bob Martinez', email: 'bob@example.com', role: 'Editor' },
{ id: 3, name: 'Clara Schmidt', email: 'clara@example.com', role: 'Viewer' },
{ id: 4, name: 'David Osei', email: 'david@example.com', role: 'Editor' },
{ id: 5, name: 'Eva Kowalski', email: 'eva@example.com', role: 'Admin' },
];
const columns = [
{ title: 'Name', field: 'name' },
{ title: 'Email', field: 'email' },
{ title: 'Role', field: 'role', lookup: { Admin: 'Admin', Editor: 'Editor', Viewer: 'Viewer' } },
];
export default function UserTable() {
const [data, setData] = useState(initialData);
return (
<MaterialTable
title="User Management"
columns={columns}
data={data}
options={{
search: true,
sorting: true,
pageSize: 5,
}}
/>
);
}
The lookup property on the Role column is worth noting. When you define a lookup object,
material-table automatically renders a dropdown select for that column in both the filter row and the
inline edit form. No custom render function needed, no separate component — just a plain object mapping
values to display labels. This pattern works beautifully for any enum-like field in your data model:
status codes, category types, priority levels. It’s one of those small API decisions that reveals the
library was designed by people who’ve actually built admin dashboards for a living.
material-table Filtering and Pagination: Fine-Tuning the User Experience
Out of the box, material-table ships with a global search bar and basic pagination. But the real power
for data-heavy applications comes from column-level filtering — giving users the ability
to filter each column independently, narrowing down thousands of rows without writing a line of filter logic.
Enable it by adding filtering: true to the options prop. A filter input
row appears immediately below the column headers, and the filtering behavior adapts automatically to the
column type: text inputs for strings, date pickers for date fields, dropdowns for lookup columns.
material-table pagination
is equally configurable. The pageSize option sets the default rows per page, while
pageSizeOptions gives users a selector to switch between predefined page sizes.
The paginationType option lets you switch between the default stepped pagination
("normal") and a “load more” style ("stepped").
For tables where row count matters at a glance, showFirstLastPageButtons: true adds
jump-to-first and jump-to-last controls. All of this lives inside the options prop —
a single configuration object that acts as the control panel for the entire table behavior:
options={{
filtering: true,
search: true,
sorting: true,
pageSize: 10,
pageSizeOptions: [5, 10, 25, 50],
paginationType: 'normal',
showFirstLastPageButtons: true,
filterRowStyle: { backgroundColor: '#f5f5f5' },
headerStyle: {
backgroundColor: '#0f3460',
color: '#ffffff',
fontWeight: 600,
},
rowStyle: (rowData, index) => ({
backgroundColor: index % 2 === 0 ? '#fafafa' : '#ffffff',
}),
}}
Notice the rowStyle function in that config. You can pass a function that receives the row
data and index, returning a style object. This enables zebra striping, conditional row highlighting
(flag overdue items in red, mark completed rows in green), and any row-level visual logic your
application needs — without touching the underlying Material-UI table components directly.
It’s a clean escape hatch that keeps your business logic out of the rendering layer.
Implementing CRUD Operations with material-table
This is where material-table CRUD functionality genuinely earns its place in
production applications. Instead of building separate modal dialogs for create/edit, managing form state,
wiring up validation displays, and coordinating optimistic updates — you hand the library three async
functions and it handles the entire interaction flow. The editable prop accepts
onRowAdd, onRowUpdate, and onRowDelete. Each must return a Promise.
When the Promise resolves, material-table exits edit mode and updates its internal state. When it rejects,
the table stays in edit mode so the user can correct errors.
Here’s a complete implementation of
React table with editing
that manages state locally — exactly the pattern you’d use before wiring up a real API:
import React, { useState } from 'react';
import MaterialTable from '@material-table/core';
const initialData = [
{ id: 1, name: 'Alice Nguyen', email: 'alice@example.com', role: 'Admin' },
{ id: 2, name: 'Bob Martinez', email: 'bob@example.com', role: 'Editor' },
];
const columns = [
{ title: 'Name', field: 'name', validate: row => row.name ? true : 'Name is required' },
{ title: 'Email', field: 'email', validate: row => row.email ? true : 'Email is required' },
{ title: 'Role', field: 'role', lookup: { Admin: 'Admin', Editor: 'Editor', Viewer: 'Viewer' } },
];
export default function CRUDTable() {
const [data, setData] = useState(initialData);
const handleRowAdd = (newRow) =>
new Promise((resolve, reject) => {
// Replace with: await api.createUser(newRow)
setTimeout(() => {
const addedRow = { ...newRow, id: Date.now() };
setData(prev => [...prev, addedRow]);
resolve();
}, 600);
});
const handleRowUpdate = (updatedRow, oldRow) =>
new Promise((resolve, reject) => {
// Replace with: await api.updateUser(updatedRow.id, updatedRow)
setTimeout(() => {
setData(prev =>
prev.map(row => (row.id === oldRow.id ? updatedRow : row))
);
resolve();
}, 600);
});
const handleRowDelete = (deletedRow) =>
new Promise((resolve, reject) => {
// Replace with: await api.deleteUser(deletedRow.id)
setTimeout(() => {
setData(prev => prev.filter(row => row.id !== deletedRow.id));
resolve();
}, 600);
});
return (
<MaterialTable
title="User Management (CRUD)"
columns={columns}
data={data}
editable={{
onRowAdd: handleRowAdd,
onRowUpdate: handleRowUpdate,
onRowDelete: handleRowDelete,
}}
options={{
actionsColumnIndex: -1,
addRowPosition: 'first',
pageSize: 5,
}}
/>
);
}
Two options in that config deserve a call-out. actionsColumnIndex: -1 pushes the edit/delete
action buttons to the rightmost column, which is the conventional UX pattern and keeps them from
competing visually with your data. addRowPosition: 'first' makes new rows appear at the top
of the table rather than the bottom — a minor detail that significantly improves usability when your table
has dozens of rows and the user just clicked “Add Row” at the top of the page. The validate
function on each column receives the current row state and returns either true (valid) or
an error string (invalid). material-table displays that string as a helper text beneath the input field,
preventing form submission until all validations pass.
Custom Actions, Column Rendering, and Advanced Customization
Beyond the built-in CRUD buttons, material-table lets you inject
custom actions
into each row via the actions prop. Each action is an object with an icon,
tooltip, and onClick handler. The handler receives the event and the row data,
so you have full access to the row’s fields when deciding what to do — navigate to a detail page,
open a confirmation modal, trigger an API call, copy data to the clipboard. You can also use a
function as the action object, which receives the row data and returns the action config. This pattern
enables conditional actions: show a “Publish” button only for draft items, disable “Delete” for
rows with dependencies.
Custom column rendering is equally powerful. Add a render function to any column definition
to take full control over how that cell displays its value. Want to render a status badge with color coding?
A clickable email link? An avatar image? A sparkline chart? All achievable with a single function that
receives the row data and returns JSX:
const columns = [
{ title: 'Name', field: 'name' },
{
title: 'Status',
field: 'status',
render: rowData => (
<span style={{
backgroundColor: rowData.status === 'Active' ? '#e8f5e9' : '#ffebee',
color: rowData.status === 'Active' ? '#2e7d32' : '#c62828',
padding: '3px 10px',
borderRadius: '12px',
fontSize: '0.82rem',
fontWeight: 600,
}}>
{rowData.status}
</span>
),
},
{
title: 'Email',
field: 'email',
render: rowData => (
<a href={`mailto:${rowData.email}`}>{rowData.email}</a>
),
},
];
The render function only affects display — filtering and sorting still operate on the raw
field value. So your status column will sort and filter correctly based on the underlying
string even though it displays as a styled badge. If you need sorting to operate on a different value
than what’s in field, use the customSort function on the column definition.
Similarly, customFilterAndSearch lets you override the default filter logic entirely —
useful for filtering across multiple fields simultaneously or implementing fuzzy search on a column.
Remote Data: Connecting material-table to a Real API
Client-side pagination works fine for hundreds of rows. It falls apart for tens of thousands.
The React data grid Material-UI pattern
for large datasets is server-side pagination — fetch only the current page from your API,
let the server handle sorting and filtering. material-table supports this natively by passing
a function as the data prop instead of an array. That function receives
a query object containing page, pageSize, search,
filters, and orderBy/orderDirection — everything your API
needs to return the right slice of data.
The function must return a Promise resolving to an object with data (the page’s rows),
page (the current zero-indexed page number), and totalCount (total rows
across all pages). material-table uses totalCount to render the correct number of
pagination pages and show accurate row count information. Here’s the remote data pattern
wired to a hypothetical REST API:
const fetchData = query =>
new Promise(async (resolve, reject) => {
try {
const { page, pageSize, search, orderBy, orderDirection } = query;
const params = new URLSearchParams({
_page: page + 1, // API is 1-indexed
_limit: pageSize,
...(search && { q: search }),
...(orderBy && { _sort: orderBy.field }),
...(orderDirection && { _order: orderDirection }),
});
const response = await fetch(`https://api.example.com/users?${params}`);
const totalCount = parseInt(response.headers.get('X-Total-Count'), 10);
const data = await response.json();
resolve({ data, page, totalCount });
} catch (err) {
reject(err);
}
});
// In your component:
<MaterialTable
title="Users (Remote)"
columns={columns}
data={fetchData}
options={{ pageSize: 10, debounceInterval: 400 }}
/>
The debounceInterval option is essential for remote data — it delays the API call by
the specified milliseconds after each keystroke in the search box, preventing a flood of requests
while the user is still typing. Set it to 300–500ms for a good balance between responsiveness
and network efficiency. material-table also handles the loading state automatically in remote mode:
a progress bar appears at the top of the table during fetches, and the rows are replaced with
skeleton placeholders on the initial load. You get a professional-looking loading experience
without writing a single line of loading state management.
Common Pitfalls and How to Avoid Them
The most frequent issue developers hit when setting up
material-table in React
for the first time is unintended re-renders causing infinite data fetch loops in
remote data mode. The culprit is almost always defining the data function inline inside the component
body. Every render creates a new function reference, which material-table detects as a change,
which triggers a new fetch, which updates state, which triggers a render — and round and round you go.
The fix: define your data function outside the component, or wrap it in useCallback with
a stable dependency array.
Another common gotcha involves the data prop mutation problem. material-table internally
adds a tableData property to each row object for tracking edit state and row metadata.
If you pass your Redux store’s array directly, material-table mutates those objects — which in strict
mode React (or with Immer-managed state) will throw errors. The clean solution: always pass a shallow
copy of your data. A quick spread in the prop — data={users.map(u => ({...u}))} —
is enough. It’s one of those things that bites you exactly once and then you never forget it.
Finally, watch your MUI version alignment. The original material-table package targets
MUI v4 (the @material-ui/* namespace). If your project uses MUI v5 (@mui/*),
you must use @material-table/core. Mixing them — using the v4-targeting package with
MUI v5 — produces a fascinating range of styling failures that are difficult to diagnose because
everything appears to import correctly. The package names changed between versions;
the error messages didn’t get the memo.
Frequently Asked Questions
How do I add editing to a material-table in React?
Pass an editable prop object to your MaterialTable component containing
onRowAdd, onRowUpdate, and onRowDelete as async functions
that return Promises. Material-table handles the entire UI — inline edit fields appear when a user
clicks the edit icon, and your handlers receive the updated row data as arguments.
Resolve the Promise to confirm the change; reject it to keep the row in edit mode for corrections.
How do I enable filtering and pagination in material-table?
Set filtering: true inside the options prop to enable per-column filter
inputs below the header row. Pagination is on by default; configure it with pageSize,
pageSizeOptions, and paginationType inside the same options
object. No additional packages or configuration are required — both features work out of the box
once you set the relevant option flags.
How do I perform CRUD operations with material-table?
Use the editable prop with three Promise-returning handlers: onRowAdd
for new rows, onRowUpdate for edits, and onRowDelete for deletions.
Inside each handler, call your API, then update your local state on successful resolution.
For new rows, material-table passes the new row data object; for updates, it passes both the
updated row and the original row (useful for finding the record in your state array);
for deletes, it passes the row being removed.
