If you’ve ever tried to build a production-grade
React data table with Redux,
you know the drill: pick a table library, bolt on Redux manually,
argue with your state shape for three hours, and eventually ship something
that half-works. Sematable exists precisely to end that loop.
It’s a React table component
built around Redux from the ground up — not an afterthought adapter,
but a first-class Redux-connected table where selection, pagination,
filtering, and sorting all live in your store by default.
This guide covers everything:
sematable installation,
Redux store setup,
column definitions,
filtering,
pagination,
server-side data handling, and real
sematable examples
you can drop straight into a project.
No fluff, no “just check the docs” deflections.
Sematable is an open-source
React table library that treats Redux as infrastructure, not
an optional plugin. Most table libraries — think react-table, AG Grid, or
MUI DataGrid — are self-contained components that manage their own internal
state. That’s fine until you need to share table state across routes, persist
filters through navigation, or sync selections with a global workflow.
At that point, “self-contained” becomes “isolated island,” and you end up
writing Redux middleware just to bridge the gap.
Sematable skips that bridge entirely. Every table instance registers
a named slice inside your Redux store. Filters, sort order, current page,
page size, and selected row IDs are all plain Redux state — readable
via getState(), dispatchable via actions, and serializable
for persistence or time-travel debugging. If you use Redux DevTools
(and you absolutely should), you’ll see every table interaction as a
discrete, inspectable action. It’s the kind of transparency that makes
debugging a pleasure rather than an archaeological dig.
This architectural choice does come with a prerequisite: you need Redux
already in your project. If you’re running a Context-only or Zustand-based
app, sematable isn’t your tool. But for teams already on a
Redux data table stack — especially those using
Redux Toolkit, redux-saga, or redux-observable — the integration
is almost frictionless. Sematable slots into your existing store
like a first-party feature, not a foreign plugin.
Getting sematable installed
takes about five minutes if you already have a Redux store.
Start with the package itself:
npm install sematable
# or
yarn add sematable
The library has peer dependencies on react, react-dom,
redux, and react-redux — all of which you presumably
already have. Once installed, the single mandatory integration step is
registering the sematable reducer in your Redux store
under the exact key 'sematable'. The library reads its own
state from that key by convention, and changing it will silently break everything:
// store.js
import { createStore, combineReducers } from 'redux';
import { sematableReducer } from 'sematable';
const rootReducer = combineReducers({
sematable: sematableReducer,
// ...your other reducers
});
export const store = createStore(rootReducer);
If you’re on Redux Toolkit, the setup is structurally identical —
just add sematable: sematableReducer inside configureStore‘s
reducer map. The reducer key is non-negotiable; the rest of your
store shape remains whatever you want. After this step,
your sematable setup is complete at the infrastructure level.
Everything else happens at the component level.
Sematable uses a higher-order component (HOC) pattern.
You define a plain table component — essentially just a render function
that describes how to display rows — and then wrap it with
sematable(tableName, columns, config).
The HOC injects all table-management props automatically:
rows (the filtered, sorted, paginated slice of data),
selectedRows, headers (column metadata with sort handlers),
and pagination controls. Your inner component stays clean and focused
purely on rendering.
Column definitions are plain JavaScript objects. Each column needs at minimum
a key (matching a property in your data objects) and a
header string. From there you can opt into sorting with
sortable: true, filtering with filterable: true,
and custom rendering with a render function.
The render function receives the raw cell value and the full
row object, so composing complex cells — status badges, action buttons,
linked names — requires no special API, just standard React:
const columns = [
{
key: 'id',
header: 'ID',
sortable: true,
primaryKey: true,
},
{
key: 'username',
header: 'Username',
sortable: true,
filterable: true,
},
{
key: 'status',
header: 'Status',
render: (val) => (
<span className={`badge badge--${val}`}>{val}</span>
),
},
{
key: 'actions',
header: '',
render: (_, row) => (
<button onClick={() => handleEdit(row)}>Edit</button>
),
},
];
One column must be marked primaryKey: true.
This is sematable’s identity field — it uses the primary key value
to track row selection, deduplication, and state persistence across
re-renders. If your data has no natural unique ID field, add one
before passing data to the component. Trying to work around the
primary key requirement will produce subtle, maddening bugs with
row selection.
Theory covers the architecture. Code covers the gaps.
Here’s a complete, working
sematable example
— a user management table with sorting, filtering, pagination,
and row selection, ready to plug into a Redux-connected React app:
// UsersTable.jsx
import React from 'react';
import sematable, { Table } from 'sematable';
const columns = [
{ key: 'id', header: 'ID', primaryKey: true, sortable: true },
{ key: 'name', header: 'Name', sortable: true, filterable: true },
{ key: 'email', header: 'Email', filterable: true },
{ key: 'role', header: 'Role', sortable: true },
];
const UsersTableInner = ({ rows, headers }) => (
<Table headers={headers}>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.name}</td>
<td>{row.email}</td>
<td>{row.role}</td>
</tr>
))}
</Table>
);
export default sematable('usersTable', columns, {
defaultPageSize: 10,
showPageSize: true,
showFilter: true,
})(UsersTableInner);
// ParentComponent.jsx
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import UsersTable from './UsersTable';
import { fetchUsers } from './usersSlice';
const UsersPage = () => {
const dispatch = useDispatch();
const users = useSelector((state) => state.users.list);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
return (
<div>
<h1>Users</h1>
<UsersTable data={users} />
</div>
);
};
export default UsersPage;
A few things worth noting in this
sematable tutorial.
The table name 'usersTable' is the Redux state key —
it must be unique per table instance in your application.
If you render two tables with the same name, they’ll share state,
which is occasionally intentional but usually catastrophic.
The data prop is the full unfiltered dataset;
sematable handles slicing it for the current page and filter state internally.
You don’t pre-filter before passing it in.
Sematable filtering
operates on two levels: a global text filter that searches across all
filterable: true columns simultaneously, and per-column
filtering if you choose to wire it up manually. The global filter
is enabled by default when you pass showFilter: true
in the config object. Sematable renders a search input above the table
and updates the Redux state on every keystroke — the filtering itself
is synchronous client-side substring matching, case-insensitive by default.
For more granular control, you can dispatch sematable’s built-in filter
actions directly from your own UI elements. This is useful when you want
custom filter dropdowns, date pickers, or range inputs that live outside
the standard search bar. Import filterChange from sematable,
dispatch it with your table name and filter value, and the table state
updates accordingly:
import { filterChange } from 'sematable';
import { useDispatch } from 'react-redux';
const RoleFilter = () => {
const dispatch = useDispatch();
const handleChange = (e) => {
dispatch(filterChange('usersTable', e.target.value));
};
return (
<select onChange={handleChange}>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
);
};
Because filter state lives in Redux, it persists across route changes
as long as your store isn’t reset. This is a subtle but powerful feature:
a user can navigate away from your users table, come back,
and their filter is still active. Whether that’s the right UX for your app
is your call — but the capability is there without any extra work,
which is more than you can say for most
React table components
that store filter state locally.
Out of the box,
sematable pagination
is client-side: pass your full dataset via data,
configure defaultPageSize, and sematable slices it.
The pagination controls — previous, next, page number buttons,
and an optional page-size selector — render automatically when
showPageSize: true is in your config.
The current page and page size live in the Redux slice for your table,
accessible via selectors if you need to read them from elsewhere
(say, for a “showing X of Y results” status bar).
Server-side pagination is where things get slightly more involved,
but it’s clean once you understand the data contract.
Instead of passing your full dataset, you pass only the current page’s rows
and add a totalCount prop equal to the total number of
records matching the current filter. Sematable uses totalCount
to calculate the correct page count and render pagination correctly
without having all rows loaded. Your job is to re-fetch from the API
whenever the Redux table state changes:
// Server-side pagination pattern
import { getTableState } from 'sematable';
import { useSelector, useDispatch } from 'react-redux';
import { useEffect } from 'react';
const UsersPageServerSide = () => {
const dispatch = useDispatch();
const tableState = useSelector((state) =>
getTableState(state, 'usersTable')
);
const { data, totalCount } = useSelector(
(state) => state.users
);
useEffect(() => {
if (!tableState) return;
dispatch(
fetchUsers({
page: tableState.page,
pageSize: tableState.pageSize,
filter: tableState.filterValue,
sortKey: tableState.sortKey,
sortDirection: tableState.sortDirection,
})
);
}, [tableState, dispatch]);
return (
<UsersTable
data={data}
totalCount={totalCount}
/>
);
};
This pattern is what makes sematable genuinely useful in production scenarios.
The table component doesn’t care whether data comes from a local array
or a paginated API — it renders whatever’s in data and
trusts totalCount for pagination math.
Your Redux thunk handles the API call. The concerns stay separated,
the code stays testable, and your
React server-side table
behaves exactly like its client-side counterpart from the user’s perspective.
Row selection is one of sematable’s strongest features — and one of the
main reasons to choose it over a simpler
React data grid solution.
Enable it by adding a selection column to your columns array
(sematable provides a built-in SortableHeader and selection
checkbox setup), and the HOC takes care of tracking which rows are selected.
The selected row IDs — derived from your primaryKey field —
are stored in Redux and accessible via the selectedRows prop
injected into your component.
Reading selection state from outside the table component is equally
straightforward. Use the getTableState selector
(exported by sematable) to pull the full table state object from Redux,
then read selectedRows from it. This is invaluable for
“bulk action” UIs — delete selected, export selected, assign role to selected —
where the action trigger lives in a toolbar component that has no
direct relationship to the table component in the React tree:
// BulkActions.jsx
import { getTableState } from 'sematable';
import { useSelector, useDispatch } from 'react-redux';
import { deleteUsers } from './usersSlice';
const BulkActions = () => {
const dispatch = useDispatch();
const tableState = useSelector((state) =>
getTableState(state, 'usersTable')
);
const selectedIds = tableState?.selectedRows ?? [];
const handleDelete = () => {
if (selectedIds.length === 0) return;
dispatch(deleteUsers(selectedIds));
};
return (
<button
disabled={selectedIds.length === 0}
onClick={handleDelete}
>
Delete ({selectedIds.length})
</button>
);
};
This architecture demonstrates why Redux integration matters for tables.
In a local-state table, getting selected rows into a sibling component
means lifting state, using a ref, or adding a callback chain through
multiple layers. With sematable’s
Redux table,
you just read the store. The component coupling problem doesn’t exist.
Sematable ships with minimal default styling — intentionally so.
The table structure outputs standard HTML <table>,
<thead>, <tbody> elements,
making it compatible with any CSS framework. Bootstrap classes work
out of the box; Tailwind works with a custom render wrapper;
CSS Modules work exactly as you’d expect. There’s no component-specific
styling system to fight against or override with !important chains.
Column headers can be customized by replacing the default
Table component with your own render logic
while still using the injected headers prop.
Each header object in the array contains the column config,
the current sort state, and a toggle() function
to trigger sorting. This gives you full control over how
sort indicators look — arrows, icons, color changes —
without overriding internal components:
const CustomHeader = ({ header }) => (
<th
onClick={header.sortable ? header.toggle : undefined}
className={header.sortKey === header.key ? 'sorted' : ''}
>
{header.title}
{header.sortable && (
<span className="sort-icon">
{header.sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
);
For teams working on design systems, sematable’s separation of
state management (Redux) from rendering (your component) is a genuine
architectural gift. The table state logic is library-owned;
the visual output is entirely yours. That boundary is clean
and stays clean. It’s worth comparing this to libraries that
bundle render logic and state together — those libraries are
faster to prototype with but harder to customize at scale.
Sematable trades first-hour speed for long-term flexibility,
which is usually the right trade for production applications.
The most common sematable bug is forgetting the primaryKey
column definition. Without it, row selection state becomes unpredictable,
and you’ll see rows toggling selection seemingly at random on re-renders.
The fix is always the same: ensure exactly one column in your definition
has primaryKey: true, and ensure the corresponding field
in your data objects contains unique, stable values.
If your API returns objects without a unique ID, add one in your
Redux reducer before storing the data.
The second most common issue is reducer key mismatch.
If you register the sematable reducer under any key other than
'sematable', the HOC won’t find its state slice
and will throw cryptic errors about undefined selectors.
It’s worth adding a comment in your store configuration
to make this explicit for teammates:
const rootReducer = combineReducers({
// ⚠️ This key MUST be 'sematable' — do not rename
sematable: sematableReducer,
users: usersReducer,
auth: authReducer,
});
Performance-wise, sematable is well-suited for datasets up to a few thousand
rows with client-side filtering and sorting. For larger datasets —
tens of thousands of rows — the client-side substring matching across
all filterable columns on every keystroke will start introducing
noticeable lag. The solution is server-side filtering as described
in the pagination section above, or adding a debounce layer to the
filter input. Sematable doesn’t debounce by default; that’s a
deliberate decision to keep the library lean. Add your own
300–400ms debounce in the component that dispatches filterChange
and the problem disappears.
Run npm install sematable, then add
sematable: sematableReducer to your Redux store’s
combineReducers call — the key must be exactly
'sematable'. Define your columns array, wrap your
inner table component with the sematable() HOC,
and pass your data array via the data prop
in the parent component. That’s the entire
sematable installation process —
no additional configuration required.
Sematable registers its own reducer inside your Redux store.
Each table instance uses a unique string name (the first argument
to the HOC) as its state key within that reducer. All table state —
current page, page size, filter value, sort column, sort direction,
and selected row IDs — lives as plain Redux state under
state.sematable[tableName]. You can read it with
the getTableState(state, tableName) selector
and dispatch sematable’s actions directly to modify it
from anywhere in your application. Full
Redux table transparency —
every interaction is a dispatchable, inspectable Redux action.
Yes. For
server-side table behavior,
pass only the current page’s data rows to the data prop
and add a totalCount prop with the total matching record count.
Use getTableState in a useEffect or Redux middleware
to detect page/filter/sort changes and trigger your API calls accordingly.
Sematable renders the correct pagination controls based on
totalCount without needing the full dataset in memory.
Your fetch logic stays in your Redux thunks or sagas —
clean separation, no coupling.
Recent Comments