# Coding agents (https://react-datatable.com/docs/coding-agents) `react-datatable` works well with coding agents because the table is installed as normal source code inside your app. After installation, agents can inspect the table implementation, your product code, your column definitions, your custom cells, and your tests in one repository. They do not need to treat the table as a black-box framework boundary. ## Give the agent the docs entrypoint [#give-the-agent-the-docs-entrypoint] When you want an agent to install or modify the table, give it the documentation index first: * [llms.txt](/docs/llms.txt) for a compact map of the docs * [llms-full.txt](/docs/llms-full.txt) for the expanded docs corpus That gives the agent the current install flow, component API, examples, and feature guides without requiring it to browse the site page by page. ## Install with an agent [#install-with-an-agent] For a new app, keep the prompt direct: ```txt Read https://react-datatable.com/docs/llms.txt, then install react-datatable into this app with: npx @react-datatable/cli install --token After installation, render a CustomersTable with static rows, stable column IDs, and getRowId. Run the relevant typecheck or build before you finish. ``` For an existing table, name the product task instead of describing the table from scratch: ```txt Read https://react-datatable.com/docs/llms-full.txt. In the customers table, add a Plan column with a text-list filter and keep the row height stable for virtualization. Use the existing column patterns and run the smallest relevant check. ``` ## Good agent tasks [#good-agent-tasks] Agents are useful for focused integration work: * installing the source into a framework app * adding or refining columns * creating custom cells * wiring an online query function to your API route * adding current-view persistence, URL sync, or saved views * adapting toolbar, preview, row, or cell UI to your product They are also useful after installation because the copied source is local. The agent can follow the implementation instead of guessing at hidden package behavior. ## What to verify [#what-to-verify] Pick the smallest check that matches the change: For visual changes, also inspect the affected table in the browser. For behavior changes, ask the agent to name which table state or API contract changed. ## Where to go next [#where-to-go-next] Install the source from [Installation](/docs/installation), then render the first table in [Getting started](/docs/quickstart/getting-started). # Data loading (https://react-datatable.com/docs/concepts/data-loading) Data loading is the question of **who owns the row set after the user changes the table**. The visible table can look the same in every mode. Search, filters, sorting, grouping, selection, preview, and display controls still belong to the table surface. What changes is where the final rows come from. ## Local Data [#local-data] Use local data when the browser can hold and transform the full working set. ```tsx row.id} /> ``` In this mode, the table receives rows through `data` and applies search, filters, sorting, and grouping in the browser. This is the simplest model. It is a good fit for: * small and medium datasets * route loaders that already fetched the rows * tables where client-side filtering is enough * prototypes before the backend contract is final ## Online Data [#online-data] Use online data when the server owns the result set. In this mode, the table turns its current state into a query and asks your backend for rows. The query includes the things users changed: filters, sorting, search, grouping, and navigation. ```txt table state -> online query -> backend result -> visible rows ``` This is the right model when: * the dataset is too large for the browser * permissions must be enforced on the backend * totals, facets, or grouped results must match server truth * the table should load page by page or window by window The table owns the interaction. Your backend owns which rows match. ## Pagination and Infinite Scroll [#pagination-and-infinite-scroll] Online mode still needs a navigation style. **Pagination** gives users explicit pages. Use it for back-office workflows where totals, page size, and repeatable navigation matter. **Infinite scroll** treats the result as one long sequence. Use it when the product should feel continuous and the user is browsing through many matching rows. Both styles use the same table state. They only differ in how the next server window is requested. ## Rendering Is Separate [#rendering-is-separate] Virtualization is not a data-loading mode. Data loading decides **which rows are available**. Virtualization decides **how many available rows are mounted in the DOM**. That means: * local tables can be virtualized * paginated tables can be virtualized * infinite tables can be virtualized * virtualization does not replace a backend query model Keeping those ideas separate makes the API easier to reason about. ## First Paint [#first-paint] If your route already loaded the first server result, pass it as initial online data. The table can use it for first paint as long as it matches the resolved table state. This matters because saved views, persistence, and URL state can change the opening query. The table should not keep prefetched rows for the wrong filters or sorting. ## Use This Rule [#use-this-rule] Choose the data mode by ownership: * browser owns the row set: use local data * server owns the row set: use online data * the question is DOM size, not row ownership: use virtualization ## Next Steps [#next-steps] * Choose a practical mode in [Choose a data mode](/docs/guides/choose-a-data-mode). * Review the exact server contract in [Online API](/docs/reference/online-api). * Tune rendering in [Tune virtualization](/docs/guides/tune-virtualization). # Persistence and Sharing (https://react-datatable.com/docs/concepts/persistence-and-sharing) Tables need memory, but not all memory means the same thing. `react-datatable` separates remembered state into three concepts: * private persistence * saved views * URL sharing They can store similar table state, but they have different product meanings. ## Private Persistence [#private-persistence] Private persistence answers: **continue where I left off**. It quietly remembers one user's current table setup. That can include filters, sorting, grouping, visible columns, column order, sizing, and the active view. A typical case is a team meeting. You filter the customers table, scroll to the right area, and open one customer to discuss the details on a separate route. When you press the browser back button, you expect to return to the same filtered table, at the same page and scroll position, without rebuilding the context by hand. In `react-datatable`, persisted table state restores the filter and layout context. Runtime restoration uses the same table identity to bring back pagination and scroll position during SPA navigation. This should feel automatic. The user does not name anything. They return to the table and it feels familiar. Use persistence for personal working state. ## Saved Views [#saved-views] Saved views answer: **take me back to this named setup**. A saved view is deliberate. It has a name and can become part of the product workflow: "Renewals", "At risk accounts", "My open issues", or "Finance review". Saved views can also support defaults: * a workspace default for the team * a user default for one person Use saved views when a table setup should be reusable, named, or shared as a team convention. ## URL Sharing [#url-sharing] URL sharing answers: **show someone this view right now**. The URL should carry only the state needed to reproduce the meaningful view: usually search, filters, sorting, grouping, and filter mode. It should not carry every personal display preference. A copied link should communicate the useful context, not another user's private workspace. Use URL state for one-off sharing and deep links. ## The Difference [#the-difference] Use this mental model: | Feature | Meaning | Visibility | | ----------- | ----------------------- | ----------------- | | Persistence | Continue my work | Private | | Saved views | Return to a named setup | Private or shared | | URL state | Open this exact context | Shared by link | This separation keeps the table predictable. A private preference should not accidentally become a team default. A shared link should not permanently change someone's remembered state. ## Defaults and Remembered State [#defaults-and-remembered-state] Defaults are starting points. Persisted state is recent work. If a user has no remembered state, a workspace or user default view can shape the first load. If they do have remembered state, the table should usually restore that instead. Shared URL state still wins because opening a link is intentional. ## Copy Link vs URL Sync [#copy-link-vs-url-sync] There are two ways to use URLs. **Copy link** exports the current shareable state when the user asks for it. This is usually the calmer default for product tables. **Continuous URL sync** keeps the address bar updated as the table changes. Use it when the URL itself is part of the product experience. Both use the same idea: URLs carry shared context, not every private table preference. ## Use This Rule [#use-this-rule] Put state in the place that matches its meaning: * personal and automatic: persistence * named and reusable: saved views * portable and temporary: URL sharing ## Next Steps [#next-steps] * See startup precedence in [State Lifecycle](/docs/concepts/state-lifecycle). * Configure private memory in [Add current-view persistence](/docs/guides/add-current-view-persistence). * Configure named views in [Add saved views](/docs/guides/add-saved-views). * Configure links in [Add copy links](/docs/guides/add-copy-links) and [Add URL sync](/docs/guides/add-url-sync). # State Lifecycle (https://react-datatable.com/docs/concepts/state-lifecycle) The table can receive state from several places. It may have product defaults, saved views, persisted user state, and URL parameters all available at the same time. The lifecycle exists so those sources produce **one opening state**, not a series of visible jumps. ## The Startup Problem [#the-startup-problem] On first load, these sources may all have an opinion: * built-in defaults * `initialState` * workspace default view * user default view * persisted current state * URL state from a shared link If they apply one by one as they load, the table can briefly show the wrong filters, the wrong active view, or the wrong first server query. ## The Startup Flow [#the-startup-flow] The table uses a simple sequence: ```txt load candidate state ↓ merge one initial state ↓ render the ready table ↓ start ongoing sync ``` The important part is that ongoing sync starts after the opening state is resolved. Persistence and URL sync should not write temporary mount state back into storage. ## Priority [#priority] When multiple sources exist, later sources win: ```txt defaults -> initialState -> workspace default view -> user default view -> persisted current state -> URL state ``` This priority keeps each source in its expected role. Workspace defaults provide a team baseline. User defaults personalize that baseline. Persisted state restores where someone left off. URL state wins because opening a shared link is an explicit action. ## Why Persisted State Beats Defaults [#why-persisted-state-beats-defaults] Defaults are starting points. Persisted state is recent work. If a user already has remembered state for a table, reopening that table should continue from their last working setup instead of resetting to a default view. Defaults matter most the first time someone opens a table, or after remembered state is cleared. ## Why URL State Wins [#why-url-state-wins] A copied link should be predictable. If a link says "show customers filtered by Enterprise and sorted by renewal date", the recipient should see that view even if they have their own stored table preferences. That is why URL state is applied last during startup. ## Online Data Depends on This [#online-data-depends-on-this] For online tables, opening state is also the first query. The table should resolve state first, then ask the backend for rows. Otherwise the user can see a loading flash for a query that was never the intended view. ## Use This Rule [#use-this-rule] Think of startup state as a coordinated handoff: * collect all state sources * choose one initial state * render from that state * then allow persistence, URL sync, and view dirty-state behavior to continue ## Next Steps [#next-steps] * Learn how memory is divided in [Persistence and Sharing](/docs/concepts/persistence-and-sharing). * Add private state memory in [Add current-view persistence](/docs/guides/add-current-view-persistence). * Add shareable links in [Add URL sync](/docs/guides/add-url-sync). # Table anatomy (https://react-datatable.com/docs/concepts/table-anatomy) This page names the main parts of a react-datatable table: the toolbar, applied state bar, grid, preview panel, bulk actions, and footer. Use it when you want to point at a specific part of the table without having to say "that thing above the rows" or "the menu near the right edge." ## How the table is put together [#how-the-table-is-put-together] In the source, `DataTableBody` brings the visible table regions together: * the toolbar * the applied state bar * the grid itself * optional visibility and bulk-selection affordances * optional preview and pagination surfaces Those pieces work as one table, but each part has its own job. Naming them makes product discussions clearer: "change the toolbar" means something different from "change the applied state bar" or "change the grid." ## 1. Toolbar [#1-toolbar] The toolbar is the top control row rendered by `DataTableToolbar`. It gives users a place to start table-wide changes before they enter the grid itself. Depending on configuration, the toolbar can include: * **Quick search** for global text search * **Filter button** for column-filter workflows * **Views dropdown** for saved views * **Display options button** for presentation controls * **Copy link** for sharing the current table state On narrower layouts, the same controls wrap into multiple rows, but they are still the same toolbar region. ## 2. Applied state bar [#2-applied-state-bar] Directly under the toolbar, `DataTableAppliedFilters` renders the applied state bar. This region shows the state that is currently affecting the result set or presentation, especially: * active filters * active sorting chips when ordering badges are visible This matters because react-datatable separates **choosing state** from **seeing what state is active**. A user may open filtering from the toolbar, apply several filters, and then manage or clear those filters from the applied state bar. That makes the bar part of the table's visible feedback loop. ## 3. Grid [#3-grid] The grid is the main scrolling table region rendered through `VirtualizedGrid`. This is where users read rows, compare values, sort from headers, resize columns, navigate with the keyboard, and interact with grouped or selected rows. The grid region includes several important sub-areas: * **Column headers** for labels, sort affordances, and resize handles * **Data rows** for the primary record surface * **Group rows** when grouping is active * **Selection column** when row selection is enabled * **Sticky or frozen columns** when left columns stay pinned during scroll The grid is the central region of the product, with support behaviors living around it. ## 4. View-columns button [#4-view-columns-button] When enabled, the **view-columns button** is the `+` control pinned near the right edge of the grid. It is a fast column-visibility affordance for people who are already working inside the table and want to change visible columns without reopening the toolbar flow. Treat it as a companion to display options, not a separate visibility system. ## 5. Bulk action island [#5-bulk-action-island] When selection is enabled and rows are selected, `BulkActionIsland` appears above the table content. This region has one job: turn selection into action. It typically shows: * the selected count * a clear-selection action * the trigger that opens the bulk action dialog The island only appears when it is relevant. That makes it a contextual surface rather than a permanent part of the layout. ## 6. Floating preview panel [#6-floating-preview-panel] When row preview is enabled and open, `FloatingPreview` renders a floating panel above the main table layout. This region is for lightweight inspection without leaving the current table context. A few anatomy details matter here: * it is an overlay, not part of the grid flow * it can persist position in session storage when configured with a table key * it is separate from row selection and separate from full row navigation That separation is useful language during product work: "open the preview" means something different from "select the row" or "open the record page." ## 7. Pagination footer or loading footer [#7-pagination-footer-or-loading-footer] In online pagination mode, the footer below the grid becomes the pagination surface. This region includes: * page-size selection * current range summary * previous and next controls * direct page navigation In other data modes, the lower edge of the table may instead express loading, empty, or virtual scrolling behavior. The important anatomy idea is that navigation and result-range feedback live below the grid, not inside the toolbar. ## How these regions work together [#how-these-regions-work-together] A typical workflow crosses several regions: 1. start in the toolbar with search, filters, views, or display options 2. confirm active state in the applied state bar 3. inspect or manipulate rows in the grid 4. branch into contextual regions like bulk actions, preview, or pagination when needed That flow is why the docs treat react-datatable as one coordinated table surface rather than a standalone grid plus unrelated extras. ## Where to go next [#where-to-go-next] * For row ownership and server-backed tables, read [Data loading](/docs/concepts/data-loading). * For first-render state precedence, read [State Lifecycle](/docs/concepts/state-lifecycle). * For implementation detail on a specific region, continue into the relevant Guides pages such as [Add global search](/docs/guides/add-global-search), [Configure display options](/docs/guides/configure-display-options), [Add row selection](/docs/guides/add-row-selection), or [Add row preview](/docs/guides/add-row-preview). # Custom React cells (https://react-datatable.com/docs/customization/custom-react-cells) Custom React cells are the main customization tool for changing **how a field renders** without changing **how the table works**. Use them after the column contract is already clear. Show one realistic table with a linked company cell, status badge, owner/avatar cell, and row action menu visible together. ## Custom cells sit on top of the column contract [#custom-cells-sit-on-top-of-the-column-contract] A custom cell extends the column definition. The column still decides the durable behavior: * the stable column ID * the underlying value source * filter and sort behavior * grouping support * saved-state compatibility The custom cell decides how that field should appear in the product UI. That distinction keeps rendering and behavior predictable. ## Reach for a custom cell when plain text stops being enough [#reach-for-a-custom-cell-when-plain-text-stops-being-enough] Custom cells are a good fit when the product needs a field to: * combine several row fields into one surface * link to a detail page * show badges, icons, avatars, or status treatments * expose a small inline action * reflect workflow meaning instead of only raw stored values If the column behavior is wrong, fix the column contract first. If the behavior is right but the UI is too generic, a custom cell is usually the right layer. ## The renderer receives row and value context [#the-renderer-receives-row-and-value-context] `DatatableColumn` supports a `cell` renderer using the TanStack-style cell context. ```tsx import type { DatatableColumn } from "./react-datatable" type Customer = { id: string company: string owner: string tier: "free" | "pro" | "enterprise" } const columns: DatatableColumn[] = [ { id: "company", accessorKey: "company", header: "Company", filterType: "text", cell: ({ row, getValue }) => ( {String(getValue())} ), }, ] ``` For simple formatting, `getValue()` is often enough. For richer domain UI, `row.original` is usually the real source of truth. ## Use `row.original` for multi-field product UI [#use-roworiginal-for-multi-field-product-ui] Many useful cells render small row summaries instead of a single field value. ```tsx cell: ({ row }) => (
{row.original.company}
{row.original.owner}
) ``` This is the normal pattern when one visual cell needs several row attributes. The important part is to keep the *behavioral* column identity stable even when the rendered UI becomes richer. ## Keep expensive work outside the renderer [#keep-expensive-work-outside-the-renderer] Cells run in the hottest rendering path of the table. That means the best custom cells are visually rich but computationally boring. Good pattern: ```tsx const money = new Intl.NumberFormat("en", { style: "currency", currency: "USD", maximumFractionDigits: 0, }) cell: ({ row }) => money.format(row.original.arr) ``` Less healthy pattern: * creating formatters on every render * doing heavy synchronous transforms inside the cell * deriving large view models repeatedly during scroll * making network calls from cells A cell should usually render quickly from data that is already available. ## Interactive cells need to cooperate with the table [#interactive-cells-need-to-cooperate-with-the-table] Buttons, menus, toggles, and links are valid reasons to use custom cells, but they need extra care. ```tsx cell: ({ row }) => ( ) ``` When a cell becomes interactive, check that it still coexists cleanly with: * row selection * keyboard navigation * row preview/open-row actions * focus order * accessibility labeling A good custom cell adds product interaction without making the grid feel unpredictable. ## Not every rich row detail belongs in a cell [#not-every-rich-row-detail-belongs-in-a-cell] Custom cells are great for compact, high-signal UI. They are usually the wrong place for: * long descriptions * activity feeds * complex forms * large stacks of actions * detail views that need sustained attention Those heavier experiences generally belong in row preview surfaces or detail routes. If a design asks one row cell to act like a mini detail page, that is usually a sign the information should move into preview UI or an open-row flow instead. ## Preserve predictable row height when possible [#preserve-predictable-row-height-when-possible] The table uses virtualization, so custom cells should avoid wildly unstable vertical layout. A cell can be visually expressive without becoming layout-chaotic. Prefer: * compact stacked text * known-size icons, badges, and avatars * concise action surfaces * predictable wrapping rules Be careful with: * unbounded text blocks * async content that changes height dramatically after render * large controls that make one row much taller than its neighbors ## Keep customization local to presentation [#keep-customization-local-to-presentation] A helpful principle is: * use the column contract to define behavior * use the custom cell to define presentation * use row presentation hooks to define state-aware styling That separation keeps a custom cell from becoming a hidden place where filtering rules, row state, and styling logic all pile up together. ## Accessibility checklist for custom cells [#accessibility-checklist-for-custom-cells] Before you call a custom cell done, check that: * links and buttons have clear text or `aria-label` * icon-only controls are still understandable * focusable elements can be reached by keyboard * the content still makes sense without color alone * inline controls do not accidentally block normal table navigation ## Use this litmus test [#use-this-litmus-test] A good custom cell should answer yes to both questions: 1. **Does this make the product table easier to understand or use?** 2. **Would the table still behave predictably if this cell were duplicated across hundreds of rows?** If the second answer is no, the UI may be too heavy for a cell surface. ## Where to go next [#where-to-go-next] * For the behavioral contract underneath the renderer, read [Define columns](/docs/guides/define-columns). * For the higher-level layer map, read [Customization overview](/docs/customization/customization-overview). * For copyable renderer patterns, read [Custom cells gallery](/docs/examples/custom-cells-gallery). * For heavier per-row detail, read [Add row preview](/docs/guides/add-row-preview) now and the preview customization page once it lands. # Customization overview (https://react-datatable.com/docs/customization/customization-overview) Customization in `react-datatable` works through a set of boundaries for shaping the table to a product while keeping the core interaction model intact. This page explains those boundaries. ## The table is meant to be adapted, not treated as a sealed widget [#the-table-is-meant-to-be-adapted-not-treated-as-a-sealed-widget] `react-datatable` lives as source in your repository. That changes the customization story. You are not limited to a tiny theme API or a fixed plugin marketplace. You can: * define product-specific columns * render custom React cells * style rows and cells from table state * wire previews, actions, and persistence to your product model * extend the copied source when a product need truly sits below the public surface The goal is **clear layers of customization**. ## Start by deciding which layer the change belongs to [#start-by-deciding-which-layer-the-change-belongs-to] Most table changes fall into one of these layers: 1. **column contract** — what the field means and how the table behaves around it 2. **rendering layer** — how cells and rows look in the product 3. **presentation hooks** — how interaction state affects styling and DOM attributes 4. **integration layer** — how the table connects to previews, routes, persistence, or product workflows 5. **source-level extension** — deeper changes inside copied table code when the public surface is not enough If you choose the wrong layer, the table can still work, but it usually becomes harder to reason about. ## Column definition is the first customization layer [#column-definition-is-the-first-customization-layer] The most important customization work usually starts before any fancy rendering. A column definition decides: * the stable column ID * the data the table reads from * whether the column sorts or groups * which filter UI the column should use * what cell renderer should appear That means column definition is the product contract between your domain data and the table's behavior. If this layer is weak, later cell customization tends to compensate for missing structure instead of building on a clean foundation. ## Custom React cells are for product-specific rendering [#custom-react-cells-are-for-product-specific-rendering] Once the column contract is solid, custom cells let the product speak in its own UI language. Use them when a cell needs to: * combine multiple fields * show badges, icons, menus, or inline actions * link to a detail page * communicate workflow-specific meaning instead of raw values This is the right layer for *how a field should render*, not for re-implementing table mechanics. A helpful rule is: let the table own filtering, sorting, grouping, and navigation; let the custom cell own domain-specific presentation. ## Row presentation hooks are for state-aware styling, not data modeling [#row-presentation-hooks-are-for-state-aware-styling-not-data-modeling] Some customization needs focus on how the table should look when state changes. `rowPresentation` exists for that layer. It lets product code react to table-owned interaction state such as: * selection * active-row focus * preview-open state * group-header versus data-row status Use this layer when ordinary CSS selectors are not enough and the product needs stable classes or `data-*` attributes derived from table state. That is different from custom cells: * **custom cells** shape the rendered content inside a column * **presentation hooks** shape styling and attributes around row/cell state ## Preview, actions, and product flows belong at the integration layer [#preview-actions-and-product-flows-belong-at-the-integration-layer] Many product teams first think of customization as visual work, but a lot of important table adaptation is behavioral integration. For example: * opening a record detail route * toggling a preview panel * attaching analytics-safe row attributes * connecting persistence or views to workspace/user identity These are product-level behaviors that sit *around* the table. The table provides structure and state for them, but your application decides what those actions actually mean. ## Customization should preserve core table responsibilities [#customization-should-preserve-core-table-responsibilities] A good customization keeps the table's underlying responsibilities recognizable. The table should still own things like: * row and column state coordination * filtering and sorting behavior * keyboard navigation semantics * virtualization and rendering performance boundaries * persistence and URL state rules If a customization begins to rewrite those concerns indirectly from a cell renderer or ad hoc CSS, that is usually a sign the change belongs in a deeper layer. ## Source ownership gives you one more escape hatch [#source-ownership-gives-you-one-more-escape-hatch] Because the table source is local, there is a final layer available when the public surfaces are not enough. That can be the right choice for changes like: * a new reusable column or renderer primitive * a new adapter capability * a bug fix in state coordination * a product-specific extension that should live near the table internals But this layer should be used deliberately. Source-level edits are powerful because they happen close to the real behavior. They are also riskier because they can blur the distinction between product integration and core mechanics. ## Use this decision guide [#use-this-decision-guide] When deciding how to implement a change, ask: ### Is this about what the field means? [#is-this-about-what-the-field-means] Use column definition. ### Is this about how the field should render? [#is-this-about-how-the-field-should-render] Use a custom cell. ### Is this about styling rows or cells from interaction state? [#is-this-about-styling-rows-or-cells-from-interaction-state] Use row presentation hooks. ### Is this about opening product workflows or storing product state? [#is-this-about-opening-product-workflows-or-storing-product-state] Use the integration surfaces around the table. ### Is the public surface genuinely insufficient? [#is-the-public-surface-genuinely-insufficient] Then consider editing the copied source directly. ## What good customization looks like [#what-good-customization-looks-like] The best customized tables feel product-specific while keeping table behavior coherent. They usually share three traits: * the column contract stays stable and explicit * product-specific rendering lives in the right UI layer * deeper source changes happen intentionally, with narrow scope When a customization request arrives, the most useful first question is usually not "can the table do this?" It is "which layer should own this?" That question tends to produce cleaner APIs, clearer docs, and less fragile product code. ## Where to go next [#where-to-go-next] * For the behavioral contract of fields, read [Define columns](/docs/guides/define-columns). * For product-specific cell rendering, read [Custom React cells](/docs/customization/custom-react-cells). * For state-aware styling, read [Styling rows and cells](/docs/customization/styling-rows-and-cells). # Customizing filter UI (https://react-datatable.com/docs/customization/customizing-filter-ui) Use this page when the default filtering behavior is right, but the filtering **surface** still needs to feel more like your product. The safest path is to customize the existing filter experience first, then replace deeper UI only when the shipped surface no longer matches the workflow. Show the toolbar filter button, searchable filter list, text-list/date submenus, and applied chips using realistic product wording. ## Start by separating filter behavior from filter UI [#start-by-separating-filter-behavior-from-filter-ui] A useful boundary is: * the **column contract** decides which fields are filterable and what filter types they use * the **filter UI** decides how those filters are presented to the user * the **backend or local row model** decides how filter payloads actually affect results That means many product-specific filter changes do **not** require replacing core filtering logic. ## Customize the names users see first [#customize-the-names-users-see-first] The filter picker uses the column's filter-facing name before it falls back to the header. That makes `meta.filterName` the first place to tune the labels shown in the interface. ```tsx const columns: DatatableColumn[] = [ { id: "owner", accessorKey: "ownerName", header: "Owner", filterType: "text-list", meta: { filterName: "Account owner", }, filterOptions: { options: owners.map((owner) => ({ value: owner.id, label: owner.name })), }, }, ] ``` Use this when the visible header is short but the filter picker needs clearer task-oriented language. ## Use filter icons and option labels to match product language [#use-filter-icons-and-option-labels-to-match-product-language] The built-in filter list also supports column-level icon and label customization through metadata and options. ```tsx { id: "status", accessorKey: "status", header: "Status", filterType: "text-list", meta: { filterName: "Customer status", filterIcon: StatusIcon, }, filterOptions: { options: [ { value: "active", label: "Active customer" }, { value: "trial", label: "Trial account" }, { value: "paused", label: "Paused" }, ], }, } ``` This is usually enough to make the filter surface feel product-aware without touching the underlying components. For option-list filters, option rows can also render custom React. Use `filterOptions.renderOption` when a plain label is not enough: ```tsx { id: "owner", accessorFn: (row) => row.owner.name, header: "Owner", filterType: "text-list", filterOptions: { options: owners.map((owner) => ({ value: owner.name, label: owner.name })), renderOption: (option) => { const owner = owners.find((item) => item.name === option.value) return ( <> {owner ? : null} {option.label} ) }, }, } ``` This only changes option presentation. Filtering still uses the selected option values. `renderOption` is available for `text-list`, `id-list`, and `boolean` filters. ## Pick filter types that produce the UI you want [#pick-filter-types-that-produce-the-ui-you-want] The built-in filter picker currently branches by `filterType`. In practice that means: * `text` and `number` open through the modal editor flow * `text-list` and `date` open through inline submenu editors * only implemented filter types appear in the searchable filter list If you want a compact pick-list experience, `text-list` is often the best fit. If you want freeform matching, use `text`. If you want range or comparison editing, use `number` or `date`. The UI shape is part of the filter-type choice, not just the backend payload choice. ## Keep the toolbar entry point aligned with the table's scope [#keep-the-toolbar-entry-point-aligned-with-the-tables-scope] The toolbar's filter button is the main entry point for column filtering. ```tsx ``` If you turn on filtering, leave applied chips visible unless the table is intentionally minimalist. The built-in experience works best when users can both **open** filters and **see** active filters in the same surface. ## Use applied chips as part of the UI design, not an afterthought [#use-applied-chips-as-part-of-the-ui-design-not-an-afterthought] Filter UI includes both the editor and the visible state it leaves behind. The applied-state bar is where users confirm, remove, and compare active filters over time. That means filter customization should include: * clear filter names * human-readable option labels * a toolbar layout that leaves enough room for the applied-state bar to matter If the filter picker is polished but the applied chips are confusing, the experience still feels unfinished. ## Use the shipped UI when it is enough [#use-the-shipped-ui-when-it-is-enough] The default filter UI is a good fit when you need to customize: * filter labels * icons * option-list labels * option-list rows with `renderOption` * which columns appear in the picker * whether filter chips are visible In those cases, stay within the built-in surface. It is cheaper, more consistent, and easier to maintain. ## Move from configuration to replacement when you need to [#move-from-configuration-to-replacement-when-you-need-to] You are probably beyond simple configuration when the product needs: * a radically different filter workflow than the searchable picker * custom editors for filter types the built-in UI does not implement * filter controls embedded somewhere other than the toolbar/applied-state pattern * a domain-specific interaction model that does not map cleanly to the current modal/submenu flows At that point, you are designing a different filtering product surface. ## Be honest about partially implemented filter types [#be-honest-about-partially-implemented-filter-types] The type system includes `custom`, but the built-in UI is strongest for: * `text` * `text-list` * `number` * `date` * `boolean` * `id-list` Treat `custom` as an extension seam unless you are also building the editor experience yourself. If a filter type exists in TypeScript but not in the actual picker/editor flow your users see, document it as an extension point rather than implying it is turnkey. ## Use this customization ladder [#use-this-customization-ladder] When filter UI needs work, move in this order: 1. choose better `filterType` values 2. improve `meta.filterName`, `meta.filterIcon`, and option labels 3. tune which columns are filterable at all 4. keep applied chips visible and understandable 5. only then consider replacing deeper UI components That sequence usually gets the product where it needs to go with much less complexity. ## Verify the filter surface in context [#verify-the-filter-surface-in-context] Before you call the filter UI done, check that: * the filter picker uses the words your users expect * list-style filters have clear human labels * the modal versus submenu behavior matches the kind of question each filter asks * active chips are understandable after the picker closes * the UI does not imply support for filter types you have not actually implemented ## Where to go next [#where-to-go-next] * For adding filter behavior itself, read [Add column filters](/docs/guides/add-column-filters). * For the surrounding surface where the filter button lives, read [Table anatomy](/docs/concepts/table-anatomy). * For the broader customization layer map, read [Customization overview](/docs/customization/customization-overview). # Customizing preview UI (https://react-datatable.com/docs/customization/customizing-preview-ui) Use this page when basic row preview already works, but the **panel surface and interaction model** still need to match your product. Preview customization is about making inspection feel intentional: light when it should stay in-table, heavier only when the workflow really needs it. Show one realistic table with the floating preview open, the close button visible, and a second annotation calling out the panel repositioned to avoid covering important columns. ## 1. Start by deciding what preview should do in your product [#1-start-by-deciding-what-preview-should-do-in-your-product] Preview is for **inspection in place**. That means a good preview usually helps users: * check row details without leaving the grid * compare nearby rows quickly * confirm context before opening a full record If the design really wants editing, multi-step actions, or route-level context, that is usually an `onOpenRow` job instead of a preview customization job. ## 2. Customize preview content through `renderPreview` [#2-customize-preview-content-through-renderpreview] The first customization seam is the preview body itself. ```tsx row.id} preview={{ renderPreview: ({ row, rowId, close }) => ( ), }} /> ``` `renderPreview` receives: * `row` for the active record * `rowId` for stable actions, analytics, or links * `close()` so your preview content can dismiss the panel directly This is the right place for product-specific summary UI, not for rewriting how preview state itself is managed. ## 3. Keep preview content denser than a full detail page [#3-keep-preview-content-denser-than-a-full-detail-page] The built-in surface is a floating panel with a bounded width and height plus internal scrolling. That makes it a strong fit for: * summary fields * owner, status, dates, and metadata * a small cluster of focused actions * short related context that helps the user decide what to do next It is a weak fit for: * long forms * multi-step editing flows * very wide comparison layouts * content that only makes sense as a whole routed screen A preview should feel like a fast inspection layer, not a cramped copy of the detail page. ## 4. Decide whether the floating panel should be draggable [#4-decide-whether-the-floating-panel-should-be-draggable] `FloatingPreview.tsx` enables dragging by default. ```tsx , }} /> ``` Use draggable preview when the panel may otherwise cover important table columns and users benefit from moving it out of the way. Set `draggable: false` when: * the table sits in a tightly controlled layout * consistency matters more than user-controlled placement * dragging would compete with another interaction model on the page ## 5. Use `storageKey` deliberately [#5-use-storagekey-deliberately] The floating preview position is stored in `sessionStorage` when a stable key exists. The current implementation uses: * `preview.floating.storageKey` if you provide one * otherwise the top-level `tableKey` That is a useful default, but it is still a product decision. Give the preview its own `storageKey` when the panel position should stay stable across visits to the same table workflow. Skip that persistence when preview placement is not important enough to preserve. ## 6. Preview open/close state is transient [#6-preview-openclose-state-is-transient] The panel position can persist. The fact that a row is currently open in preview should not be treated like saved table state. That boundary matters. Preview visibility reflects what the user is inspecting *right now*. It is not part of a durable layout contract like filters, sorting, or views. If a product wants deep-linkable inspection state, that is usually a route or URL-state design question, not ordinary preview customization. ## 7. Customize preview behavior without blurring it with row navigation [#7-customize-preview-behavior-without-blurring-it-with-row-navigation] The grid already treats preview as part of row interaction: * clicking a data row can open preview when preview is configured * `Space` can toggle preview from the active row * `Esc` closes preview before clearing broader interaction state * keyboard movement can carry preview along with the active row These defaults are useful, but they need to fit the product workflow. If users cannot tell whether a row click means preview, selection, or navigation, the product needs a clearer interaction contract before more UI polish will help. ## 8. Use `onTogglePreviewRow` for product side effects, not the preview body [#8-use-ontogglepreviewrow-for-product-side-effects-not-the-preview-body] If the product needs analytics or side effects around preview activity, use `rowActions.onTogglePreviewRow`. ```tsx { trackPreviewToggle(rowId, nextOpen) }, }} preview={{ renderPreview: ({ row }) => , }} /> ``` This keeps instrumentation and workflow hooks at the row-action boundary instead of burying them inside the preview content component. ## 9. Source-level preview customization starts with `FloatingPreview.tsx` [#9-source-level-preview-customization-starts-with-floatingpreviewtsx] When prop-level customization is not enough, the narrow source seam is `features/preview/FloatingPreview.tsx`. That is the right place to change things like: * the panel chrome * close affordance styling * header layout * container sizing * drag-handle treatment * the outer shell around your `renderPreview` content Be more careful when changing: * viewport clamping * persistence behavior * drag math * open/close semantics * keyboard expectations tied to preview state Those behaviors shape how the table feels, not just how the panel looks. If the product only needs a different preview presentation, keep the existing preview state model and replace the panel shell first. That is the safer customization seam. ## 10. Review preview in the real table context [#10-review-preview-in-the-real-table-context] Before you call preview customization done, check that: * the content helps inspection without turning into a mini app * the panel does not cover critical table data in a frustrating way * dragging and stored position behave predictably when enabled * the close affordance is obvious and easy to reach * `Space` and `Esc` still make sense in the overall row workflow * row click, selection, preview, and full navigation still feel distinct ## Where to go next [#where-to-go-next] * For the initial feature wiring, read [Add row preview](/docs/guides/add-row-preview). * For the broader map of customization layers, read [Customization overview](/docs/customization/customization-overview). * For the lighter row-level rendering layer before preview, read [Custom React cells](/docs/customization/custom-react-cells). * For deeper source edits to shipped surfaces, read [Replacing built-in UI elements](/docs/customization/replacing-built-in-ui-elements). # Customizing saved view UI (https://react-datatable.com/docs/customization/customizing-saved-view-ui) Use this guide when saved views are the right feature, but the shipped dropdown and dialogs still need to match your product language and collaboration model. Show the views trigger with an active view selected, the dirty-state dot visible, separate Private and Workspace sections in the dropdown, and focused crops of the create and rename dialogs. ## 1. Start by deciding whether your product should expose views at all [#1-start-by-deciding-whether-your-product-should-expose-views-at-all] Saved views are for **named presets**. Use them when users should be able to: * return to a meaningful table setup later * share a preset with teammates * promote a preset to a personal or workspace default Do not use them as a substitute for quiet autosave. That job belongs to `persistState`. ## 2. Shape the visible views workflow from the top-level `views` config [#2-shape-the-visible-views-workflow-from-the-top-level-views-config] The views trigger only appears when both pieces exist: * a `views` adapter configuration * `toolbar.views: true` ```tsx ``` This is the first customization boundary: decide whether the surface exists before you change its labels or layout. ## 3. Treat adapter capability as part of the UI design [#3-treat-adapter-capability-as-part-of-the-ui-design] The shipped saved view UI changes based on adapter support. `react-datatable` checks whether the adapter can: * share views with a workspace * set user defaults * set workspace defaults That means the product should only promise actions the adapter can really perform. For example: * `localStorageDatatableViewAdapter` supports private views and user defaults * it does **not** support sharing or workspace defaults * a backend adapter can support the full collaboration workflow If the table is anonymous, public, or single-user, disable collaboration-oriented affordances explicitly. ```tsx ``` ## 4. Keep the two list sections meaningful: Private and Workspace [#4-keep-the-two-list-sections-meaningful-private-and-workspace] The current dropdown groups views into: * **Private** — views that are not shared and not acting as shared defaults * **Workspace** — shared views plus views carrying workspace or user-default meaning That grouping is useful because it tells users whether a view is personal or social. When you customize the labels or visual design, preserve that distinction. A saved view should not look identical whether it is: * visible only to one user * shared with the workspace * acting as a default for everyone ## 5. Customize naming and labels to fit the product domain [#5-customize-naming-and-labels-to-fit-the-product-domain] Most view-surface customization starts with language. Good candidates for product-specific wording include: * what your product calls a “view” * create and rename dialog descriptions * share-action wording * default labels and helper text * empty-state text for first-time users The shipped UI currently uses generic language such as: * `Views` * `Create new view` * `Share with workspace` * `Set as my default` * `Set workspace default` If your product has a clearer term such as “Queue,” “Preset,” or “Saved search,” this is a good source-level customization target. ## 6. Preserve the dirty-state workflow even if the visuals change [#6-preserve-the-dirty-state-workflow-even-if-the-visuals-change] The current views trigger shows a dot when the active saved view has unsaved changes. Inside the dropdown, an active dirty view can expose: * `Update` for overwriting the active view * `Save as` for creating a new view from the current state That is an important behavior contract. It tells users three different things clearly: * which named preset is active * whether they have drifted away from it * whether they are editing the current preset or creating a new one You can restyle these controls, but avoid collapsing them into a single ambiguous save action. ## 7. Decide carefully which users can rename, delete, share, or default a view [#7-decide-carefully-which-users-can-rename-delete-share-or-default-a-view] The shipped menu already gates actions by ownership and capability. Examples from the current source: * rename and delete only appear for the view owner * share only appears for private views when sharing is supported * workspace default only appears for shared views * user defaults are shown only when default management is enabled That means UI customization should not be only decorative. It should also keep permission meaning legible. A helpful pattern is: * keep destructive actions secondary * make ownership obvious when views are shared * avoid showing unavailable actions as dead controls ## 8. Use the dialogs to clarify what state a view actually captures [#8-use-the-dialogs-to-clarify-what-state-a-view-actually-captures] The create dialog currently promises to save: * filters * sorting * column layout * display options That is a good baseline because it reminds users that a view is a durable table preset, not just a bookmark. If your product adds stronger sharing or governance language, the create and rename dialogs are often the right place to explain it. For example, you may want to clarify: * whether defaults affect only the current user * whether sharing is irreversible * whether a workspace default becomes the first experience for teammates ## 9. Separate views customization from persistence customization [#9-separate-views-customization-from-persistence-customization] Saved views and current-view persistence reuse a similar state snapshot while serving different product surfaces. Keep these roles distinct in the UI: * **persistence** remembers private in-progress work automatically * **saved views** are named presets users manage intentionally * **URL sharing** sends a specific query state through a link If a customization starts making views feel like invisible autosave, the product meaning gets blurry fast. When teams say “the table should remember what I was doing,” that usually means `persistState`. When they say “I need a reusable setup I can choose again,” that means saved views. Keep the UI language aligned with that difference. ## 10. Edit the copied source when you need a different workflow [#10-edit-the-copied-source-when-you-need-a-different-workflow] The shipped surface is implemented in the copied views components, including: * `DatatableViewDropdown.tsx` * `ViewsDropdownContent.tsx` * `ViewItem.tsx` * `CreateViewDialog.tsx` * `RenameViewDialog.tsx` Edit those files directly when the product needs deeper changes such as: * different grouping or section rules * a richer share flow * custom badges for ownership or defaults * extra metadata in each view row * a different trigger shape or menu layout When you do, keep the underlying model intact: * active view selection * dirty-state detection * adapter capability gating * ownership-based actions * distinction between private, shared, and default states ## 11. Verify the views surface like a collaboration workflow, not a single button [#11-verify-the-views-surface-like-a-collaboration-workflow-not-a-single-button] Before shipping a customized saved view UI, check that: * the trigger only appears when views are truly enabled * private versus workspace meaning is visually clear * unsupported actions are hidden, not implied * dirty-state update/save-as actions remain understandable * rename, delete, and sharing flows respect ownership * default actions match the adapter's real capabilities * the UI still distinguishes named presets from autosaved current state ## Where to go next [#where-to-go-next] * For the memory model behind this UI, read [Persistence and Sharing](/docs/concepts/persistence-and-sharing). * For the practical setup of the feature itself, read [Add saved views](/docs/guides/add-saved-views). * For the toolbar cluster that contains this trigger, read [Customizing toolbar and controls](/docs/customization/customizing-toolbar-and-controls). * For deeper source-level swaps after prop-level customization runs out, read [Replacing built-in UI elements](/docs/customization/replacing-built-in-ui-elements). # Customizing toolbar and controls (https://react-datatable.com/docs/customization/customizing-toolbar-and-controls) Use this guide when the default toolbar structure is close to right, but the control mix still needs to match your product. The toolbar is already a composed surface. Most customization starts by deciding **which built-in controls belong there** before you replace any deeper UI. Show a realistic toolbar with quick search and filters on the left, views plus display controls on the right, and one product-specific action called out as a source-level extension. ## 1. Start by choosing whether the toolbar exists at all [#1-start-by-choosing-whether-the-toolbar-exists-at-all] The toolbar can be disabled entirely or enabled with defaults. ```tsx ``` ```tsx ``` Use `false` only when the table is intentionally minimal or the surrounding screen already provides the same controls elsewhere. ## 2. Turn individual built-in controls on and off from `toolbar` [#2-turn-individual-built-in-controls-on-and-off-from-toolbar] Most real customization starts with the object form. ```tsx ``` This decides whether the toolbar shows: * quick search * the filter button * the display-options button * the copy-link button * the saved-views dropdown * the applied sorting and filter chip bar below the toolbar ## 3. Treat the toolbar and applied-state bar as one workflow [#3-treat-the-toolbar-and-applied-state-bar-as-one-workflow] The toolbar is where users *change* table state. The applied-state bar is where they *see and clear* that state. That means toolbar customization should usually include a decision about `appliedState` too. ```tsx toolbar={{ filterButton: true, appliedState: { showFilters: true, showSorting: false, }, }} ``` A common healthy pattern is: * keep filter chips visible when filtering matters * hide sorting chips only when sorting is mostly a quiet default * avoid removing both the entry point and the visible feedback for the same control ## 4. Some toolbar controls depend on top-level features [#4-some-toolbar-controls-depend-on-top-level-features] A toolbar button exposes a feature entry point, while the feature's deeper behavior lives in its own configuration and state model. Two important examples: * `toolbar.views: true` only shows the views dropdown if the top-level `views` adapter config also exists * `toolbar.displayOptions: true` only shows the button; the top-level `displayOptions` prop still decides what appears inside the popover ```tsx ``` The toolbar controls the **entry point**. The top-level feature config controls the **behavior behind it**. ## 5. Tune quick search and filters to match the table's real job [#5-tune-quick-search-and-filters-to-match-the-tables-real-job] The toolbar source places quick search and the filter button on the left because they are the primary query controls. Keep that logic intact unless the workflow truly differs. Good uses: * quick search for fast broad lookup * filter button for field-specific narrowing * applied chips for visible active state Less healthy uses: * hiding query controls while expecting users to understand active filtering * adding too many top-level controls until search and filters stop feeling primary If query behavior needs work, customize the underlying guides first instead of treating the toolbar as a cosmetic shell. ## 6. Use views, display options, and copy link as secondary controls [#6-use-views-display-options-and-copy-link-as-secondary-controls] The shipped toolbar keeps these controls on the right as secondary table-management actions: * **Views** for named working states * **Display options** for presentation controls * **Copy link** for sharing the current URL state That separation is useful because it keeps querying distinct from layout and sharing. If a product hides one of these controls, do it because the workflow truly does not need it, not because the button cluster feels visually busy. ## 7. Expect the toolbar to wrap on narrow widths [#7-expect-the-toolbar-to-wrap-on-narrow-widths] The copied source already changes layout on smaller containers. In narrow mode it becomes a multi-row surface: 1. quick search plus filter button 2. views on the left and display/copy controls on the right 3. the Match all/Match any toggle when active filters require it That means toolbar customization should be judged in both wide and narrow layouts. A control mix that looks fine on desktop can become cramped or confusing once it wraps. ## 8. Use `viewColumnsButton` when visibility changes belong near the grid [#8-use-viewcolumnsbutton-when-visibility-changes-belong-near-the-grid] Not every control has to live in the toolbar. `viewColumnsButton` adds the pinned `+` affordance near the table grid for fast column visibility changes. ```tsx ``` Use this when users frequently toggle visible columns while actively scanning rows, not only from the top toolbar. ## 9. The current limit: there is no public arbitrary toolbar slot [#9-the-current-limit-there-is-no-public-arbitrary-toolbar-slot] The copied `DataTableToolbar` source has an internal `children` slot, while the public `Datatable` API shown in `props.types.ts` keeps toolbar composition fixed. That means you can safely customize the shipped toolbar surface through: * feature toggles * quick-search config * applied-state visibility * top-level views and display-options configuration * the separate `viewColumnsButton` If you need product-specific buttons, extra segmented controls, or a different toolbar composition, you are beyond public configuration and into source-level customization. Because the table lives in your repository, you can edit `DataTableToolbar.tsx` directly when the product needs custom controls beyond the current public API. Do that deliberately, and treat it as a deeper customization layer than ordinary prop tuning. ## 10. Verify the toolbar in context before shipping [#10-verify-the-toolbar-in-context-before-shipping] Before you call the toolbar done, check that: * primary query controls still feel primary * applied chips stay visible when users need feedback * views and display options only appear when their backing config exists * the control mix still works after the layout wraps on narrow widths * column-visibility controls live in the most natural place for the workflow * extra product actions only enter the toolbar when prop-level customization is truly insufficient ## Where to go next [#where-to-go-next] * For the surface this control cluster belongs to, read [Table anatomy](/docs/concepts/table-anatomy). * For search behavior inside the toolbar, read [Add global search](/docs/guides/add-global-search). * For the display-options popover behind one of these controls, read [Configure display options](/docs/guides/configure-display-options). * For the broader decision of whether a change belongs in props or source, read [Customization overview](/docs/customization/customization-overview). # Replacing built-in icons (https://react-datatable.com/docs/customization/replacing-built-in-icons) The copied table source keeps built-in table icons behind one proxy file: ```txt src/react-datatable/icons.tsx ``` That file currently re-exports Phosphor icons. The rest of the table imports from the local proxy instead of importing an icon library directly. This means you can replace the table's built-in icon set without hunting through toolbar, filter, view, grid, dialog, and pagination components one by one. ## What the kit uses today [#what-the-kit-uses-today] The core kit uses `@phosphor-icons/react` through `icons.tsx`. Icons are used for table-owned controls such as: * sort direction * filters * column visibility * saved views * copy link * pagination * dialogs * empty and error states * row preview close buttons * bulk-action controls The docs site and showcase examples also use `lucide-react` for product examples. Those are not part of the core table icon proxy. ## Replace the proxy, not every component [#replace-the-proxy-not-every-component] Start by opening: ```tsx // src/react-datatable/icons.tsx ``` The default file looks like this conceptually: ```tsx export { ArrowDownIcon, ArrowUpIcon, CheckIcon, FunnelIcon, XIcon, // ... } from "@phosphor-icons/react" export type { Icon } from "@phosphor-icons/react" ``` If you want to use another icon set, keep the exported names stable and change what they point to. ## Example: replace built-ins with Lucide [#example-replace-built-ins-with-lucide] Some Phosphor icons are passed a `weight` prop in the table source. Lucide icons do not use that prop, so wrap Lucide once and drop unsupported props there. ```tsx import type { ComponentType, SVGProps } from "react" import { ArrowDown, ArrowLeftRight, ArrowLeft, ArrowRight, ArrowUp, Bookmark, Calendar, Check, ChevronDown, Circle, Copy, Eye, EyeOff, Filter, GripVertical, Hash as HashIcon, Link, List as ListIcon, ListFilter, MoreHorizontal, Plus, RotateCcw, Ruler, Save, Search, Settings, SlidersHorizontal, Snowflake as SnowflakeIcon, Trash, TriangleAlert, Type, Users as UsersIcon, Workflow, X, type LucideIcon, } from "lucide-react" export type Icon = ComponentType< SVGProps & { size?: string | number weight?: unknown } > function asDatatableIcon(IconComponent: LucideIcon): Icon { return function DatatableIcon({ weight: _weight, ...props }) { return } } export const ArrowCounterClockwiseIcon = asDatatableIcon(RotateCcw) export const ArrowDownIcon = asDatatableIcon(ArrowDown) export const ArrowUpIcon = asDatatableIcon(ArrowUp) export const ArrowsLeftRight = asDatatableIcon(ArrowLeftRight) export const CloseIcon = asDatatableIcon(X) export const BookmarkSimpleIcon = asDatatableIcon(Bookmark) export const CalendarBlank = asDatatableIcon(Calendar) export const CaretDown = asDatatableIcon(ChevronDown) export const CaretDownIcon = asDatatableIcon(ChevronDown) export const CheckIcon = asDatatableIcon(Check) export const ChevronDownIcon = asDatatableIcon(ChevronDown) export const ChevronLeftIcon = asDatatableIcon(ArrowLeft) export const ChevronRightIcon = asDatatableIcon(ArrowRight) export const DotsSixVertical = asDatatableIcon(GripVertical) export const DotsSixVerticalIcon = asDatatableIcon(GripVertical) export const DotsThreeIcon = asDatatableIcon(MoreHorizontal) export const MoreHorizontalIcon = asDatatableIcon(MoreHorizontal) export const EyeIcon = asDatatableIcon(Eye) export const EyeSlashIcon = asDatatableIcon(EyeOff) export const FunnelIcon = asDatatableIcon(Filter) export const FunnelSimpleIcon = asDatatableIcon(ListFilter) export const Gear = asDatatableIcon(Settings) export const Gradient = asDatatableIcon(Circle) export const Hash = asDatatableIcon(HashIcon) export const LinkIcon = asDatatableIcon(Link) export const List = asDatatableIcon(ListIcon) export const ListBullets = asDatatableIcon(ListIcon) export const MagnifyingGlassIcon = asDatatableIcon(Search) export const PlusIcon = asDatatableIcon(Plus) export const RadioDotIcon = asDatatableIcon(Circle) export const RulerIcon = asDatatableIcon(Ruler) export const SlidersHorizontalIcon = asDatatableIcon(SlidersHorizontal) export const Snowflake = asDatatableIcon(SnowflakeIcon) export const SortAscendingIcon = asDatatableIcon(ArrowUp) export const SortDescendingIcon = asDatatableIcon(ArrowDown) export const TextT = asDatatableIcon(Type) export const ToggleLeft = asDatatableIcon(Circle) export const TrashIcon = asDatatableIcon(Trash) export const Users = asDatatableIcon(UsersIcon) export const WarningCircleIcon = asDatatableIcon(TriangleAlert) export const WarningIcon = asDatatableIcon(TriangleAlert) export const XIcon = asDatatableIcon(X) export const AddIcon = PlusIcon export const AlertIcon = WarningCircleIcon export const SaveIcon = asDatatableIcon(Save) export const SaveAsNewIcon = asDatatableIcon(Copy) export const WorkflowIcon = asDatatableIcon(Workflow) ``` Treat this as a starting point. Pick the exact icons that fit your product language. ## Keep product icons out of the proxy [#keep-product-icons-out-of-the-proxy] The proxy is for table-owned chrome. Use column and cell code for product-specific icons: ```tsx const columns = [ { id: "status", accessorKey: "status", header: "Status", filterType: "text-list", meta: { filterName: "Status", filterIcon: StatusIcon, }, cell: ({ row }) => , }, ] ``` That keeps the table's control icons separate from domain icons like status, priority, owner, health, or billing state. ## Check the result [#check-the-result] After replacing the proxy, scan the table surfaces that use icons: * toolbar buttons * filter chips and filter editors * display options * saved views * column header actions * empty and error states * pagination and calendar controls * row preview and bulk-action surfaces Then run TypeScript. If the icon set does not support a prop the table passes, adapt it in the proxy instead of changing every caller. ## Where to go next [#where-to-go-next] * For changing the toolbar around those icons, read [Customizing toolbar and controls](/docs/customization/customizing-toolbar-and-controls). * For filter-specific labels and icons, read [Customizing filter UI](/docs/customization/customizing-filter-ui). * For replacing larger UI surfaces, read [Replacing built-in UI elements](/docs/customization/replacing-built-in-ui-elements). # Replacing built-in UI elements (https://react-datatable.com/docs/customization/replacing-built-in-ui-elements) Use this page when the shipped UI is **structurally close** to what you need, but a real product requirement now demands editing the copied source itself. This is the point where `react-datatable` stops feeling like a package and starts behaving like product-owned UI. Show the default toolbar or filter control beside a product-specific replacement, with annotations calling out what stayed stable: state hooks, table props, and interaction behavior. ## 1. Replace built-in UI only after configuration stops working [#1-replace-built-in-ui-only-after-configuration-stops-working] Stay on the public surface first when the job is only to: * hide or show shipped controls * rename filter labels and options * change how cells render * tune display-options sections * wire existing preview or views behavior into your app Cross into source edits when the product needs something the public API does not expose cleanly, such as: * a different control arrangement * different button shapes, icons, or interaction patterns * extra product actions in the toolbar * a replacement filter entry flow * a branded or workflow-specific control surface that still uses the same table state underneath ## 2. Choose the smallest replacement seam [#2-choose-the-smallest-replacement-seam] Do not start by rewriting half the table. Pick the narrowest component that owns the UI you actually need to change. Common seams in the copied source include: * `components/toolbar/DataTableToolbar.tsx` for control arrangement * `components/toolbar/QuickSearch.tsx` for search input shape and clear-button behavior * `components/toolbar/FilterButton.tsx` for the filter trigger and badge treatment * `components/toolbar/DisplayOptionsButton.tsx` for the display-options entry point * `components/DatatableViewDropdown/*` for saved-views trigger and dialog behavior A narrow seam gives you a smaller diff, a clearer review surface, and fewer accidental behavior regressions. ## 3. Keep the contract, replace the shell [#3-keep-the-contract-replace-the-shell] The safest replacement pattern is: 1. keep the same state hooks and inputs 2. keep the same output behavior 3. swap the rendered UI shell around them For example, `QuickSearch.tsx` already owns the debounce, Cmd/Ctrl+F shortcut, Escape-to-clear behavior, and store sync. If your product needs a different search field visual treatment, edit that component before you touch broader table logic. Likewise, `FilterButton.tsx` already owns the open state and active-filter badge count. If the product needs a different trigger layout, keep those behaviors and replace the button shell around them. ## 4. Rearranging toolbar controls is a source-level customization [#4-rearranging-toolbar-controls-is-a-source-level-customization] The public `toolbar` prop toggles built-in controls while keeping composition within the shipped toolbar surface. That makes `DataTableToolbar.tsx` the right seam when you need to: * move controls into a different cluster * insert a product-specific action beside shipped controls * change wrapped-layout behavior on narrow widths * promote or demote a control based on product workflow When editing the toolbar, preserve the distinction between: * **query controls** like quick search and filters * **table-management controls** like views, display options, and copy link That grouping is part of the table's usability and interaction clarity. ## 5. Preserve store subscriptions and state boundaries [#5-preserve-store-subscriptions-and-state-boundaries] A lot of the shipped UI is intentionally narrow in what it subscribes to. For example: * `FilterButton` subscribes to `columnFilters.length`, not the whole filter array * `QuickSearch` keeps a local input value, then debounces writes into the datatable store * `DataTableToolbar` computes wrapped-layout details without owning query logic itself When replacing built-in UI, keep those boundaries unless you have a strong reason not to. If you casually widen subscriptions or move store logic into more components, the UI can still work while becoming noisier to maintain and easier to regress. ## 6. Replace interaction text and visuals freely, but be careful with semantics [#6-replace-interaction-text-and-visuals-freely-but-be-careful-with-semantics] Safe things to customize aggressively: * button text * icons * spacing and sizing * class names and tokens * arrangement of existing controls * dialog and dropdown presentation details Things to treat more carefully: * keyboard shortcuts * focus order * badge meaning * open/close interaction rules * whether a control writes state immediately or only after confirmation The product can absolutely look different. The dangerous part is changing what users can rely on without meaning to. ## 7. Prefer one owned wrapper over many scattered hacks [#7-prefer-one-owned-wrapper-over-many-scattered-hacks] If several screens need the same replacement, consolidate it. A healthy pattern is to make one deliberate product-owned version of the control or toolbar instead of scattering tiny overrides across many routes. That gives you: * one place to evolve the UI * one place to review state behavior * one place to update when the underlying table internals change Because the source is local, your replacement can live right next to the shipped component and still stay easy to audit. ## 8. Do not smuggle core behavior changes into a cosmetic rewrite [#8-do-not-smuggle-core-behavior-changes-into-a-cosmetic-rewrite] Source-level UI replacement is still a **UI** customization unless you explicitly decide otherwise. If you are editing a built-in control, avoid quietly changing deeper behavior such as: * URL serialization rules * persistence merge order * selection semantics * filter payload meaning * view-sharing permissions Those are legitimate changes, and they belong to a deeper feature or state rewrite than swapping a button or replacing a dropdown shell. If a task changes both the visible control and the underlying rules, split the work mentally and verify each part on purpose. Cosmetic and behavioral edits bundled together are much harder to trust. ## 9. A practical replacement workflow [#9-a-practical-replacement-workflow] When you replace a built-in UI element, follow this order: 1. identify the smallest owning component 2. read the current source and note which hooks and props it depends on 3. keep those contracts stable while changing the rendered shell 4. verify the control in the real table context 5. only then decide whether deeper refactoring is justified This workflow keeps product customization fast without turning the copied table into a pile of ad hoc experiments. ## 10. Review the result like a product surface [#10-review-the-result-like-a-product-surface] Before you call a replacement done, check that: * the control still reaches the same table state it used before * keyboard and focus behavior still make sense * the replacement still works in narrow layouts where relevant * state badges, labels, and empty states remain understandable * the diff changed the intended component seam, not unrelated table logic * future developers can still tell where product code ends and table mechanics begin ## Where to go next [#where-to-go-next] * For prop-level control of the shipped toolbar before replacement, read [Customizing toolbar and controls](/docs/customization/customizing-toolbar-and-controls). * For lighter filter-surface changes before component swaps, read [Customizing filter UI](/docs/customization/customizing-filter-ui). * For the broader map of which layer should own a change, read [Customization overview](/docs/customization/customization-overview). * For guidance on delegating narrow source edits safely, read [Coding agents](/docs/coding-agents). # Styling rows and cells (https://react-datatable.com/docs/customization/styling-rows-and-cells) Use this guide when the table needs visual states that ordinary CSS selectors cannot infer on their own. `rowPresentation` is the styling seam for translating table-owned interaction state into your design system's classes and `data-*` attributes. ## 1. Treat styling as a separate customization layer from rendering [#1-treat-styling-as-a-separate-customization-layer-from-rendering] A common mistake is to push every visual need into custom cell renderers. That works for content, but it is the wrong layer for many styling concerns. `rowPresentation` exists so the product can style rows and cells from table-owned state such as: * selection * active-row focus * preview-open state * whether a row is a data row or a group header That makes it the right layer for **state-aware styling**, not content rendering. ## 2. Let the table own the state and your app own the styling decision [#2-let-the-table-own-the-state-and-your-app-own-the-styling-decision] The table already knows whether a row is selected, active, or currently previewed. `rowPresentation` lets your application consume that state and decide what classes or DOM attributes should appear. ```tsx isActive || isPreviewOpen ? "bg-primary/5" : undefined, }} /> ``` This is the important split: * the table decides **what state is true** * your app decides **how that state should look** ## 3. Choose the right hook for the surface you want to style [#3-choose-the-right-hook-for-the-surface-you-want-to-style] The API exposes separate hooks for rows and cells because those are different visual surfaces. * `getRowClassName` styles the whole row shell * `getCellClassName` styles an individual cell * `getRowAttributes` adds row-level attributes * `getCellAttributes` adds cell-level attributes That lets you keep broad row treatment and precise cell treatment independent. ## 4. Branch on row kind when grouped tables need different treatment [#4-branch-on-row-kind-when-grouped-tables-need-different-treatment] `getRowClassName` and `getRowAttributes` run for both normal data rows and group headers. That is why the callback receives `rowKind`. ```tsx getRowClassName={(info) => { if (info.rowKind === "group-header") return "font-medium text-muted-foreground" return info.isSelected ? "bg-muted" : undefined }} ``` This is useful because grouped tables often need different styling logic for structural rows versus actual data rows, but they should still participate in one coherent styling system. ## 5. Use row styling for interaction state, not hidden business meaning [#5-use-row-styling-for-interaction-state-not-hidden-business-meaning] A good use of row styling: * selected rows get a muted background * active keyboard row gets a stronger outline * preview-open rows get a subtle emphasis * group headers get a distinct structural treatment A less healthy use: * encoding important business meaning only through background color * hiding core status semantics inside classes with no textual cue * using row hooks to patch over missing data modeling in the column layer The styling seam should reinforce meaning that already exists, not create the only source of meaning. ## 6. Use cell styling for local emphasis and per-column polish [#6-use-cell-styling-for-local-emphasis-and-per-column-polish] `getCellClassName` is a good fit for targeted treatment such as: * right-aligning numeric columns * muting secondary metadata * emphasizing a risk/status column * applying stable per-column visual differences Because it receives `columnId`, it can express per-column logic without requiring each custom cell to repeat the same styling rules. ```tsx getCellClassName={({ columnId }) => columnId === "revenue" ? "text-right tabular-nums" : undefined } ``` ## 7. Prefer attributes for testing and instrumentation seams [#7-prefer-attributes-for-testing-and-instrumentation-seams] `getRowAttributes` and `getCellAttributes` support styling, testing, and instrumentation through one stable seam. ```tsx getRowAttributes={({ rowKind, rowId }) => ({ "data-row-kind": rowKind, "data-row-id": rowId, })} ``` ```tsx getCellAttributes={({ columnId }) => ({ "data-column-id": columnId, })} ``` This is usually better than tying tests to visible text that may change frequently. ## 8. Keep selected, active, and preview-open states visually distinct [#8-keep-selected-active-and-preview-open-states-visually-distinct] These three states can overlap while still carrying different interaction meaning. * **selected** means included in selection state * **active** means the keyboard/navigation cursor is on this row * **preview-open** means the row is driving a preview surface If all three use identical styling, the table becomes harder to read. Treat them as related but distinct signals. ## 9. Prefer hooks over ad hoc DOM reach-ins [#9-prefer-hooks-over-ad-hoc-dom-reach-ins] Because virtualization, grouping, and selection change the rendered structure over time, styling hacks that depend on fragile DOM traversal tend to break. `rowPresentation` is the stable surface for this job because it sits close to the table's own row-state model. ## 10. Use a simple boundary when deciding between styling and rendering [#10-use-a-simple-boundary-when-deciding-between-styling-and-rendering] If the change is about **what content appears**, use a custom cell.\ If the change is about **how table-owned state should style the row or cell shell**, use `rowPresentation`. That boundary keeps customization easier to maintain. It translates internal table state into your design system's classes and attributes, without forcing every individual cell renderer to understand selection, active-row state, or preview semantics. ## Where to go next [#where-to-go-next] * For the broader customization map, read [Customization overview](/docs/customization/customization-overview). * For content-level rendering changes, read [Custom React cells](/docs/customization/custom-react-cells). * For the surrounding surface these styles act on, read [Table anatomy](/docs/concepts/table-anatomy). # Avatar select cell (https://react-datatable.com/docs/examples/avatar-select-cell) This example builds an owner picker with avatars, search, and an unassigned state. ## Build the cell component [#build-the-cell-component] The main point here is simple: custom cells are just React code. Start by building the cell UI the same way you would build any other React component. ```tsx import { Check, ChevronsUpDown } from "lucide-react" import { useState } from "react" import { SearchableDropdown } from "@/components/searchable-dropdown" type Owner = { id: string name: string initials: string color: string } export function AvatarSelectCell({ value, owners, onChange, }: { value: Owner | null owners: Owner[] onChange: (owner: Owner | null) => void }) { const [open, setOpen] = useState(false) const items = [ { id: "unassigned", name: "Unassigned", initials: "\u2014", color: "#94a3b8" } as Owner, ...owners, ] return ( item.id} filterFn={(item, search) => item.name.toLowerCase().includes(search.trim().toLowerCase())} onSelect={(item) => onChange(item.id === "unassigned" ? null : item)} renderItem={(item, selected) => ( {item.initials} {item.name} {selected ? )} searchPlaceholder="Assign owner..." width="w-[240px]" > ) } ``` This component renders the current owner, opens the picker, and calls `onChange` when someone picks a new value. ```tsx import { useEffect, useRef } from "react" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" export function SearchableDropdown({ open, onOpenChange, items, renderItem, getItemKey, onSelect, searchPlaceholder = "Search...", filterFn, width = "w-56", children, }: SearchableDropdownProps) { const searchInputRef = useRef(null) const { search, setSearch, selectedIndex, handleKeyDown, filteredItems } = useDropdownKeyboardNav({ items, onSelect: (item) => { onSelect(item) onOpenChange(false) }, filterFn, onEscape: () => onOpenChange(false), }) useEffect(() => { if (!open) { setSearch("") } }, [open, setSearch]) return ( {children && {children}}
setSearch(event.target.value)} placeholder={searchPlaceholder} className="inline-input h-8 border-none bg-transparent px-2 py-1" />
{filteredItems.map((item, index) => ( ))}
) } ```
The avatar cell uses a reusable dropdown helper. You do not need this exact helper in your own app, but it is worth showing here because it contains the search, popover, and keyboard behavior that make the picker work. ## Connect it to a column [#connect-it-to-a-column] Next, wire the component into the column definition. The cell handles the UI. The column tells the table which field it is rendering. ```tsx const columns: DatatableColumn[] = [ { id: "owner", header: "Owner", accessorFn: (row) => row.owner?.name ?? "Unassigned", width: 220, enableSorting: false, filterType: "text-list", filterOptions: { options: owners.map((owner) => ({ value: owner.name, label: owner.name })), renderOption: (option) => { const owner = owners.find((item) => item.name === option.value) return ( <> {owner ? ( {owner.initials} ) : null} {option.label} ) }, }, cell: ({ row }) => ( handleOwnerChange(row.original.id, owner)} /> ), }, ] ``` The cell renderer and the filter option renderer are separate extension points. The cell controls how the value appears inside the grid; `filterOptions.renderOption` controls how each owner appears in the built-in text-list filter menu. ## Full table example [#full-table-example] This is the full pattern in one place: the table owns the row state, the custom cell emits the new owner, and the table owns the optimistic update and refetch. The column order here is deliberate: keep the interactive owner picker near the front so people can actually see and use it without scrolling past low-value fields first. In the example, `useCustomersPage(queryState)` stands in for your page-level data hook. That hook gives the table both the current rows and `refetchPage()`. The cell stays focused on rendering and interaction only. ```tsx function CustomersTable() { // Your page-level data hook owns the current query and exposes a way to refresh it. const { rows: serverRows, refetchPage } = useCustomersPage(queryState) const [rows, setRows] = useState(serverRows) useEffect(() => { setRows(serverRows) }, [serverRows]) const handleOwnerChange = async (id: string, owner: Owner | null) => { // Optimistic update: show the new owner in the table immediately. setRows((current) => current.map((row) => (row.id === id ? { ...row, owner } : row))) try { await api.customers.updateOwner({ id, ownerId: owner?.id ?? null }) await refetchPage() // Re-sync the current query with server truth after the save. } catch { await refetchPage() // Restore server truth if the mutation failed. } } const columns = [ { id: "name", header: "Contact", accessorKey: "name", width: 180 }, { id: "owner", header: "Owner", accessorFn: (row) => row.owner?.name ?? "Unassigned", width: 220, filterType: "text-list", filterOptions: { options: owners.map((owner) => ({ value: owner.name, label: owner.name })), renderOption: (option) => { const owner = owners.find((item) => item.name === option.value) return ( <> {owner ? ( {owner.initials} ) : null} {option.label} ) }, }, cell: ({ row }) => ( handleOwnerChange(row.original.id, owner)} /> ), }, { id: "company", header: "Company", accessorKey: "company", width: 190 }, { id: "status", header: "Status", accessorKey: "status", width: 140 }, { id: "health", header: "Health", accessorKey: "health", width: 140 }, { id: "renewal", header: "Renewal", accessorKey: "renewal", width: 150 }, ] satisfies DatatableColumn[] return row.id} toolbar={false} /> } ``` ## Update flow [#update-flow] This section matters because the cell UI is only half the job. For a real table, readers also need to know where the save logic lives and why the value appears to change immediately. This example uses the normal production pattern: an optimistic update. * the cell emits the new owner * the table updates local row state immediately * the table sends the mutation in the background * on success, the table refetches the current query to sync with server truth * on failure, the same refetch restores the correct server state ```tsx const handleOwnerChange = async (id: string, owner: Owner | null) => { // Optimistic update: update local row state before the request finishes. setRows((current) => current.map((row) => (row.id === id ? { ...row, owner } : row))) try { await api.customers.updateOwner({ id, ownerId: owner?.id ?? null }) await refetchPage() // Re-run the current table query to sync with server truth. } catch { await refetchPage() // Roll back to server truth if the save failed. } } ``` If your table is local-only, you may not need a refetch at all. If it is backed by server data, keep the refetch in the table or page layer so the cell never has to know how persistence works. The cell should render the owner picker and emit changes. The table or page layer should own row state, mutations, and refetching. # Boolean toggle cell (https://react-datatable.com/docs/examples/boolean-toggle-cell) Use this when a row owns one simple true-or-false state such as paying, enabled, or verified. ## Build the React component [#build-the-react-component] This is one of the simplest interactive cells possible: render the switch and make sure it does not fight the row click behavior around it. ```tsx import { Switch } from "@/components/ui/switch" export function BooleanToggleCell({ value, onChange, }: { value: boolean onChange: (value: boolean) => void }) { return (
event.stopPropagation()}>
) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] The field stays a boolean in the column contract. The cell only turns that boolean into a compact switch UI. ```tsx { id: "paying", header: "Paying", accessorKey: "paying", width: 100, enableSorting: false, enableFiltering: false, cell: ({ row }) => ( updateRow(row.original.id, { paying })} /> ), } ``` ## Render it in a small table [#render-it-in-a-small-table] The demo table shows the switch in context so you can verify that the row still feels like a table row rather than a loose form. ```tsx function PayingExampleTable() { const [rows, setRows] = useState(seedRows) const updateRow = (id: string, patch: Partial) => { setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row))) } return row.id} toolbar={false} /> } ``` ## Persist changes with optimistic update [#persist-changes-with-optimistic-update] For backend writes, optimistic boolean flips are straightforward: update the row, send the mutation, then refetch to confirm authority. ```tsx async function updatePaying(id: string, paying: boolean) { setRows((current) => current.map((row) => (row.id === id ? { ...row, paying } : row))) try { await api.customers.updateBillingState({ id, paying }) await refetchCustomers() } catch { await refetchCustomers() } } ``` Always stop event propagation for switch-like controls so the cell does not accidentally trigger row open or preview actions. # Currency trend cell (https://react-datatable.com/docs/examples/currency-trend-cell) Use this when people need to scan both a number and its direction at the same time. ## Build the React component [#build-the-react-component] This cell is still just presentation: a formatted number plus a small trend icon driven by row data already in memory. ```tsx import { Minus, TrendingDown, TrendingUp } from "lucide-react" type Trend = "up" | "down" | "flat" const iconByTrend = { up: TrendingUp, down: TrendingDown, flat: Minus, } satisfies Record const toneByTrend = { up: "text-emerald-600", down: "text-red-500", flat: "text-muted-foreground", } export function CurrencyTrendCell({ value, trend, }: { value: number trend: Trend }) { const Icon = iconByTrend[trend] return ( {"$" + value.toLocaleString()} ) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] Keep the column anchored to the numeric revenue field, then let the renderer enrich the visual treatment with trend context. ```tsx { id: "revenue", header: "Revenue", accessorKey: "revenue", width: 160, enableSorting: false, enableFiltering: false, cell: ({ row }) => ( ), } ``` ## Render it in a small table [#render-it-in-a-small-table] The mini table keeps only the revenue cell custom so the table-level integration remains obvious. ```tsx const columns: DatatableColumn[] = [ { id: "name", header: "Contact", accessorKey: "name" }, { id: "company", header: "Company", accessorKey: "company" }, { id: "revenue", header: "Revenue", accessorKey: "revenue", cell: ({ row }) => ( ), }, ] row.id} toolbar={false} /> ``` This pattern works well when the extra visual signal stays tiny. If the metric needs real analysis, that belongs in preview or a detail view. # Custom cells gallery (https://react-datatable.com/docs/examples/custom-cells-gallery) Use this page when the table contract is already stable and you want a concrete cell implementation to copy. The live interactive version is on the [landing page custom-cells section](/#custom-cells). ## Available examples [#available-examples] * [Avatar select cell](/docs/examples/avatar-select-cell) * [Single select cell](/docs/examples/single-select-cell) * [Multi-select cell](/docs/examples/multi-select-cell) * [Date select cell](/docs/examples/date-select-cell) * [Editable text cell](/docs/examples/editable-text-cell) * [Progress cell](/docs/examples/progress-cell) * [Currency trend cell](/docs/examples/currency-trend-cell) * [Link cell](/docs/examples/link-cell) * [Boolean toggle cell](/docs/examples/boolean-toggle-cell) ## How to use this set [#how-to-use-this-set] 1. Open the cell page closest to what you need. 2. Copy the component implementation. 3. Keep sorting, filtering, grouping, and row identity in the column definition. 4. Adapt the visual treatment to your product without changing the underlying table contract accidentally. Pre-create formatters, memoize expensive children when needed, and push large detail or multistep workflows into row preview instead of overloading the hottest render path in the table. # Date select cell (https://react-datatable.com/docs/examples/date-select-cell) Use this when a row needs one compact date field such as renewal, due date, or scheduled handoff. ## Build the React component [#build-the-react-component] The cell only needs to show the current date clearly and open a small picker. Keep the rest of the date logic outside the render path. ```tsx import { CalendarIcon } from "lucide-react" import { format } from "date-fns" import { useState } from "react" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" export function DateSelectCell({ value, onChange, }: { value: string onChange: (value: string) => void }) { const [open, setOpen] = useState(false) const date = new Date(value + "T00:00:00Z") return ( { if (!next) return onChange(next.toISOString().slice(0, 10)) setOpen(false) }} /> ) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] The column still keeps the durable field identity. The cell just gives that date field a compact inline picker. ```tsx { id: "renewal", header: "Renewal", accessorKey: "renewal", width: 150, enableSorting: false, enableFiltering: false, cell: ({ row }) => ( updateRow(row.original.id, { renewal })} /> ), } ``` ## Render it in a small table [#render-it-in-a-small-table] This table renders one custom renewal column beside plain text columns so the integration stays easy to copy. ```tsx function RenewalExampleTable() { const [rows, setRows] = useState(seedRows) const updateRow = (id: string, patch: Partial) => { setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row))) } return row.id} toolbar={false} /> } ``` ## Persist changes with optimistic update [#persist-changes-with-optimistic-update] In production, optimistic date updates are usually enough: patch the row locally, send the mutation, then refetch for backend truth. ```tsx async function updateRenewal(id: string, renewal: string) { setRows((current) => current.map((row) => (row.id === id ? { ...row, renewal } : row))) try { await api.customers.updateRenewal({ id, renewal }) await refetchCustomers() } catch { await refetchCustomers() } } ``` Date cells work best when they stay compact and predictable. If you need scheduling workflows, that usually belongs outside the grid. # Editable text cell (https://react-datatable.com/docs/examples/editable-text-cell) Use this when a row needs lightweight inline text edits without turning the table into a spreadsheet. ## Build the React component [#build-the-react-component] The React component is the whole story here: switch between read and edit mode, keep a local draft, and commit on blur or Enter. ```tsx import { useEffect, useState } from "react" import { Input } from "@/components/ui/input" export function EditableCell({ value, onChange, }: { value: string onChange: (value: string) => void }) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(value) useEffect(() => { setDraft(value) }, [value]) if (!editing) { return ( ) } return ( { onChange(draft.trim() || value) setEditing(false) }} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { onChange(draft.trim() || value) setEditing(false) } if (event.key === "Escape") { setDraft(value) setEditing(false) } }} value={draft} /> ) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] The column keeps a stable text field for the table. The cell only manages the local editing surface. ```tsx { id: "notes", header: "Notes", accessorKey: "notes", width: 240, enableSorting: false, enableFiltering: false, cell: ({ row }) => ( updateRow(row.original.id, { notes })} /> ), } ``` ## Render it in a small table [#render-it-in-a-small-table] The mini table shows the inline editor in context without stacking multiple editable cell types into the same demo. ```tsx function NotesExampleTable() { const [rows, setRows] = useState(seedRows) const updateRow = (id: string, patch: Partial) => { setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row))) } return row.id} toolbar={false} /> } ``` ## Persist changes with optimistic update [#persist-changes-with-optimistic-update] For backend sync, keep the optimistic patch small and let refetch clean up any validation or canonical formatting differences. ```tsx async function updateNotes(id: string, notes: string) { setRows((current) => current.map((row) => (row.id === id ? { ...row, notes } : row))) try { await api.customers.updateNotes({ id, notes }) await refetchCustomers() } catch { await refetchCustomers() } } ``` Inline text editing should feel lightweight. If the field needs validation rules, helper text, or multi-step input, move it out of the cell. # Infinite Scroll Table (https://react-datatable.com/docs/examples/infinite-scroll-table) This example keeps fetching and shaping rows on the server by using the datatable in **infinite online** mode, so users scan one continuous list instead of jumping between numbered pages. The live table below behaves like a real product surface: each interaction sends an `OnlineQueryInput` to this site’s demo API, and the table paints whatever rows, totals, and filter metadata come back. When someone opens a multi-select list filter, the response can include **facets** so each choice shows a count that matches the current filters; the field names and shapes are documented under [Online API](/docs/reference/online-api). Use viewport virtualization so the DOM stays bounded while the server returns scrolling windows. Swap the column definitions and `query` function for your own route and data layer. Keep stable column IDs, a dataset-level `queryKey`, and backend-owned totals so the footer and filters stay honest. Before you start, install the datatable source into your app and add the server helpers when you need them. See [Installation](/docs/installation) for the `npx @react-datatable/cli install` command and how to use your license token. ## 1. Shared row type [#1-shared-row-type] Define the row shape **once** (for example in `shared/customer.ts` or `contracts/customer.ts`) and import it from both your React table and your API handler. That way the columns, `getRowId`, and `OnlineQueryResponse` stay aligned with the JSON your route actually returns, without duplicating stringly-typed fields. The browser still does not load the full dataset: the API returns one scrolling window of rows (plus totals metadata and `hasMore`) at a time, scoped by tenant, permissions, and whatever else your handler enforces. ```tsx // shared (import from the same module in your Vite app and in your server process) type Customer = { id: string company: string name: string status: string plan: string region: string seats: number revenue: number owner: string renewal: string } ``` ## 2. Define columns [#2-define-columns] This step is **client-only**: you configure `DatatableColumn` so the table knows headers, widths, and which built-in filter and sort UIs to show. Give every column a stable `id`. When you add the route in **step 7**, reuse those same ids in your server column map so filters and sorts line up with what the table sends. ```tsx // client import type { DatatableColumn } from "./react-datatable" const statuses = ["Active", "Trial", "Paused"] as const const columns: DatatableColumn[] = [ { id: "company", header: "Company", accessorKey: "company", width: 240, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 150, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, ] ``` ## 3. Online infinite mode [#3-online-infinite-mode] Use `online` instead of `data`, and set `mode: "infinite"`. The table owns interaction state; the backend owns matching rows, totals, facets, and `hasMore` so the scroll range stays honest. Set `pageSize` and `prefetchRows` to control how large each server window is and how far ahead to load. ```tsx // client import { Datatable } from "./react-datatable" row.id} online={{ mode: "infinite", queryKey: ["customers", workspaceId], pageSize: 80, prefetchRows: 160, supportedGroupingColumns: ["status"], query: fetchCustomersPage, }} initialState={{ sorting: [{ id: "company", desc: false }], }} /> ``` ## 4. Implement the query function [#4-implement-the-query-function] The smallest useful implementation is a `fetch` that posts JSON and returns `OnlineQueryResponse`. The route sketch in step 7.6 uses `runInMemoryDatatableQuery` on every row you load for the tenant (unfiltered within that scope); the helper applies `input` in memory. That is easy to validate but does not scale if the slice is huge, so plan to replace it with a database-backed query while keeping the same route contract. See [Server helper API](/docs/reference/server-helper-api) and [Server query endpoint](/docs/guides/server-query-endpoint). ```tsx // client import type { OnlineQueryInput, OnlineQueryResponse } from "react-datatable-server" async function fetchCustomersPage(input: OnlineQueryInput): Promise> { const response = await fetch("/api/customers/table", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }) if (!response.ok) { throw new Error("Failed to load customers") } return response.json() } ``` ## 5. Add the toolbar and selection configuration [#5-add-the-toolbar-and-selection-configuration] Online mode moves row computation to the server; you can still expose search, filters, display options, selection, and bulk actions on the client. ```tsx // client toolbar={{ quickSearch: { placeholder: "Search Customers..." }, filterButton: true, displayOptions: true, copyLink: false, views: false, }} selection={{ enabled: true, mode: "multi", showCheckboxOnHover: false, allowSelectAllMatching: true, }} ``` ## 6. Viewport virtualization [#6-viewport-virtualization] Infinite loading decides **which server windows are fetched** as the user scrolls. Viewport virtualization decides **how many loaded rows the browser mounts**. They compose cleanly: ```tsx // client virtualization={{ mode: "viewport", rowOverscanCount: 12, }} ``` ## 7. Implement the server-side route [#7-implement-the-server-side-route] This walkthrough uses **Hono** and **Drizzle** so you have a concrete, copy-pasteable server file. You can use any web framework and data layer you like; what matters is that your handler accepts `OnlineQueryInput` as JSON and returns `OnlineQueryResponse` for the same row type as the table. Shape that handler like a normal HTTP API route: method, path, JSON body, and JSON response are covered in [Server query endpoint](/docs/guides/server-query-endpoint). Expose one HTTP route (for example `POST /api/customers/table`) on your API process. It should read the JSON body as `OnlineQueryInput` and respond with `OnlineQueryResponse` for the same row type you share with the client in step 1. ### 7.1 Install packages [#71-install-packages] ### 7.2 Open Postgres and create a Drizzle client [#72-open-postgres-and-create-a-drizzle-client] Create one `pg.Pool` from `DATABASE_URL` for the whole process, then pass it to `drizzle`. Reuse that `db` instance from your route handlers. ```ts // server import { drizzle } from "drizzle-orm/node-postgres" import { Pool } from "pg" const pool = new Pool({ connectionString: process.env.DATABASE_URL }) export const db = drizzle(pool) ``` ### 7.3 Model the table with Drizzle [#73-model-the-table-with-drizzle] Add a `pgTable` that matches your real migration: primary key, a tenant or `workspace_id` column for scoping, and every column your `DatatableColumn` definitions read through `accessorKey` or `accessorFn`. ```ts // server/schema/customers.ts (example: grow this to match step 2 and your migrations) import { integer, pgTable, text } from "drizzle-orm/pg-core" export const customers = pgTable("customers", { id: text("id").primaryKey(), workspaceId: text("workspace_id").notNull(), company: text("company").notNull(), contactName: text("contact_name").notNull(), status: text("status").notNull(), plan: text("plan").notNull(), region: text("region").notNull(), seats: integer("seats").notNull(), revenue: integer("revenue").notNull(), owner: text("owner").notNull(), renewal: text("renewal").notNull(), }) ``` ### 7.4 Map SQL rows to your shared row type [#74-map-sql-rows-to-your-shared-row-type] `db.select()` returns rows in the shape of your Drizzle schema. Map each row to the same `Customer` (or product) interface the React table uses (for example, map `contact_name` in SQL to `name` in TypeScript) so the rest of the pipeline works with one type. ```ts // server/map-customer.ts import type { Customer } from "../../shared/customer" import type { customers } from "./schema/customers" type CustomerRow = typeof customers.$inferSelect export function mapRow(row: CustomerRow): Customer { return { id: row.id, company: row.company, name: row.contactName, status: row.status, plan: row.plan, region: row.region, seats: row.seats, revenue: row.revenue, owner: row.owner, renewal: row.renewal, } } ``` ### 7.5 Import the same column definitions as the client [#75-import-the-same-column-definitions-as-the-client] Import the `DatatableColumn[]` from step 2 (in a real repo, move that array to a small shared module and import it from both Vite and the server bundle). The column `id` values are what the table sends in `filters` and `sorting`; your server logic must recognize those ids. ```ts // server/customers-table-route.ts import type { Customer } from "../../shared/customer" import { customerOnlineColumns } from "../../shared/customer-online-columns" // Export `customerOnlineColumns` once from shared/customer-online-columns.ts (the same // array you use in step 2) so filters and sorts hit the same column ids on the server. ``` ### 7.6 Implement `POST /api/customers/table` [#76-implement-post-apicustomerstable] 1. Resolve the signed-in user and `workspaceId` (or equivalent tenant scope). 2. `const input = (await c.req.json()) as OnlineQueryInput`. 3. Load rows for that scope only, for example `await db.select().from(customers).where(eq(customers.workspaceId, workspaceId))`. 4. Map rows with your mapper from 7.4 into `Customer[]`. 5. Turn `input` plus `data` into an `OnlineQueryResponse` (see the snippet below and the note that follows it). Return `c.json(response)` with the correct `content-type` for JSON. ```ts // server/customers-table-route.ts import { Hono } from "hono" import { eq } from "drizzle-orm" import type { OnlineQueryInput } from "react-datatable-server" import { runInMemoryDatatableQuery } from "react-datatable-server" import { customerOnlineColumns } from "../../shared/customer-online-columns" import { customers } from "./schema/customers" import { db } from "./db" import { mapRow } from "./map-customer" export function registerCustomersTableRoute(app: Hono) { app.post("/api/customers/table", async (c) => { const workspaceId = c.req.header("x-workspace-id") ?? "demo_workspace" const input = (await c.req.json()) as OnlineQueryInput const rows = await db.select().from(customers).where(eq(customers.workspaceId, workspaceId)) const data = rows.map(mapRow) const response = await runInMemoryDatatableQuery({ data, columns: customerOnlineColumns, input, getRowId: (row) => row.id, }) return c.json(response) }) } ``` `runInMemoryDatatableQuery` takes the **unfiltered** row array you pass as `data` (for example every mapped customer in the workspace) and applies the online query shape from `input` in process memory: filters, sorts, grouping, pagination or scroll windows, and the other fields the table sends. You do **not** pre-slice `data` to the rows the grid is showing; the helper returns the correct slice and metadata (including `hasMore` in infinite mode) in `OnlineQueryResponse`. That makes it a fast way to stand up the route and **verify online mode and column wiring** before you build real SQL. It is **not** a good fit for large datasets, because each request loads the full scoped table (or whatever subset your `where` returns) into server RAM. When behavior looks right in the browser, move the same contract to a database-backed planner or query builder so filtering and paging happen in the engine instead of in Node. [Server query endpoint](/docs/guides/server-query-endpoint) is the place to start for that handoff. ### 7.7 Mount Hono and register the route [#77-mount-hono-and-register-the-route] Create `const app = new Hono()`, call your register function from step 7.6, then export `app` for your runtime (Bun, Node with your adapter, etc.). ```ts // server/app.ts import { Hono } from "hono" import { registerCustomersTableRoute } from "./customers-table-route" const app = new Hono() registerCustomersTableRoute(app) export default app ``` The all-in-one server snippet under **Full examples** inlines the same mapper, columns, and handler in a single file instead of splitting them across modules. The **same handler** also powers [Paginated Table](/docs/examples/paginated-table): the client sets `online.mode` to `"pagination"` and uses explicit pages instead of scroll windows; the server still reads `input.mode`, `input.offset`, and `input.limit` from the JSON body. The live table above uses the full showcase columns, row preview, and CSV export wired to this site’s `/api/showcase/export`. The copyable blocks below are a smaller baseline you can paste into your own repository; follow [Server query endpoint](/docs/guides/server-query-endpoint) to implement `POST /api/customers/export` (or your chosen path) for the same `serverExecutor` contract. ## Full examples [#full-examples]
Expand to copy a minimal infinite online table (React) ```tsx // client import { useMemo } from "react" import { Datatable, type DataTableBulkAction, type DataTableBulkServerActionRequest, type DatatableColumn, } from "./react-datatable" import type { OnlineQueryInput, OnlineQueryResponse } from "react-datatable-server" type Customer = { id: string company: string name: string status: "Active" | "Trial" | "Paused" plan: string region: string seats: number revenue: number owner: string renewal: string } const statuses = ["Active", "Trial", "Paused"] as const async function fetchCustomersPage(input: OnlineQueryInput): Promise> { const response = await fetch("/api/customers/table", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }) if (!response.ok) { throw new Error("Failed to load customers") } return response.json() } /** POST /api/customers/export. Same selection payload contract as the bulk-actions guide. */ async function customersBulkServerExecutor(request: DataTableBulkServerActionRequest): Promise { if (request.actionId !== "export-customers-csv") { return } const response = await fetch("/api/customers/export", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ selection: request.selection, payload: request.payload, }), }) if (!response.ok) { throw new Error(`Export failed: ${response.status}`) } const blob = await response.blob() const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = "customers-export.csv" link.click() URL.revokeObjectURL(url) } export function CustomersInfiniteTable({ workspaceId }: { workspaceId: string }) { const columns = useMemo[]>( () => [ { id: "company", header: "Company", accessorKey: "company", width: 240, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 140, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, ], [] ) const bulkActions = useMemo[]>( () => [ { id: "export-selection", title: "Export selected CSV", execution: "server", serverActionId: "export-customers-csv", buildServerPayload: () => ({ requestedBy: "customers-table" }), }, ], [] ) return (
row.id} online={{ mode: "infinite", queryKey: ["customers", workspaceId], pageSize: 80, prefetchRows: 160, supportedGroupingColumns: ["status"], query: fetchCustomersPage, }} toolbar={{ quickSearch: { placeholder: "Search Customers..." }, filterButton: true, displayOptions: true, copyLink: false, views: false, }} initialState={{ sorting: [{ id: "company", desc: false }], }} virtualization={{ mode: "viewport", rowOverscanCount: 12 }} selection={{ enabled: true, mode: "multi", showCheckboxOnHover: false, allowSelectAllMatching: true, }} bulkActions={{ triggerLabel: "Actions", actions: bulkActions, serverExecutor: customersBulkServerExecutor, }} />
) } ```
Expand to copy a minimal online table server (Hono + Drizzle) ```ts // server /** * customers-table-api.ts: single-file sketch you can split into schema/, routes/, etc. * Serves POST /api/customers/table for both pagination and infinite online modes. * * Teaching default: `runInMemoryDatatableQuery` runs the online planner on the scoped * row array. Replace that call with SQL-backed planning when the workspace slice is too * large to load whole; keep the same JSON request/response contract. */ import { Hono } from "hono" import { drizzle } from "drizzle-orm/node-postgres" import { eq } from "drizzle-orm" import { Pool } from "pg" import { integer, pgTable, text } from "drizzle-orm/pg-core" import type { OnlineQueryInput } from "react-datatable-server" import { runInMemoryDatatableQuery } from "react-datatable-server" import type { DatatableColumn } from "./react-datatable" // --- Drizzle schema (match your migrations) --- const customers = pgTable("customers", { id: text("id").primaryKey(), workspaceId: text("workspace_id").notNull(), company: text("company").notNull(), contactName: text("contact_name").notNull(), status: text("status").notNull(), plan: text("plan").notNull(), region: text("region").notNull(), seats: integer("seats").notNull(), revenue: integer("revenue").notNull(), owner: text("owner").notNull(), renewal: text("renewal").notNull(), }) type CustomerRow = typeof customers.$inferSelect type Customer = { id: string workspaceId: string company: string name: string status: "Active" | "Trial" | "Paused" plan: string region: string seats: number revenue: number owner: string renewal: string } function mapRow(row: CustomerRow): Customer { return { id: row.id, workspaceId: row.workspaceId, company: row.company, name: row.contactName, status: row.status as Customer["status"], plan: row.plan, region: row.region, seats: row.seats, revenue: row.revenue, owner: row.owner, renewal: row.renewal, } } const statuses = ["Active", "Trial", "Paused"] as const // --- Keep in sync with the React table (extract to shared/ in real apps) --- const customerOnlineColumns: DatatableColumn[] = [ { id: "company", header: "Company", accessorKey: "company", width: 240, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "name", header: "Contact", accessorKey: "name", width: 190, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 140, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, { id: "plan", header: "Plan", accessorKey: "plan", width: 130, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: ["Starter", "Team", "Enterprise"].map((plan) => ({ label: plan, value: plan })), }, }, { id: "region", header: "Region", accessorKey: "region", width: 120, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: ["NA", "EU", "APAC"].map((region) => ({ label: region, value: region })), }, }, { id: "seats", header: "Seats", accessorKey: "seats", width: 110, enableSorting: true, enableFiltering: true, filterType: "number", }, { id: "revenue", header: "Revenue", accessorKey: "revenue", width: 130, enableSorting: true, enableFiltering: true, filterType: "number", }, { id: "owner", header: "Owner", accessorKey: "owner", width: 170, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "renewal", header: "Renewal", accessorKey: "renewal", width: 140, enableSorting: true, enableFiltering: true, filterType: "date", }, ] const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const db = drizzle(pool) const app = new Hono() app.post("/api/customers/table", async (c) => { // Replace with real session / tenant resolution. const workspaceId = c.req.header("x-workspace-id") ?? "demo_workspace" const input = (await c.req.json()) as OnlineQueryInput const rows = await db.select().from(customers).where(eq(customers.workspaceId, workspaceId)) const data = rows.map(mapRow) const response = await runInMemoryDatatableQuery({ data, columns: customerOnlineColumns, input, getRowId: (row) => row.id, }) return c.json(response) }) export default app ``` For a minimal local run, create the pool from `DATABASE_URL`, start Hono on a port, and point the React `fetch` URL at `http://127.0.0.1:8787/api/customers/table` (or mount this `app` behind your framework’s dev proxy under `/api/customers/table`). When you outgrow the in-memory helper, follow [Server query planning](/docs/guides/server-query-planning) and [Server query execution](/docs/guides/server-query-execution) to push filters, sorts, and pagination into the database.
## Where to go next [#where-to-go-next] * For the data-boundary decision, read [Choose a data mode](/docs/guides/choose-a-data-mode). * For request and response shapes, read [Online API](/docs/reference/online-api). * For explicit pages instead of continuous loading, read [Paginated Table](/docs/examples/paginated-table). * For the HTTP contract used by online tables and server bulk payloads, read [Server query endpoint](/docs/guides/server-query-endpoint). # Link cell (https://react-datatable.com/docs/examples/link-cell) Use this when a field should open a detail route or external destination directly from the table. ## Build the React component [#build-the-react-component] This component keeps link navigation and row interaction from conflicting by stopping the row click from hijacking navigation. ```tsx import { ExternalLink } from "lucide-react" export function LinkCell({ href }: { href: string }) { const label = new URL(href).hostname.replace(/^www\./, "") return ( event.stopPropagation()} rel="noreferrer" target="_blank" > {label} ) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] The column still exposes the field as a normal URL string while the cell decides how to render a friendlier label. ```tsx { id: "website", header: "Website", accessorKey: "website", width: 190, enableSorting: false, enableFiltering: false, cell: ({ row }) => , } ``` ## Render it in a small table [#render-it-in-a-small-table] The table demo keeps the website column custom and leaves the rest plain so the link behavior is easy to reason about. ```tsx const columns: DatatableColumn[] = [ { id: "name", header: "Contact", accessorKey: "name" }, { id: "company", header: "Company", accessorKey: "company" }, { id: "website", header: "Website", accessorKey: "website", cell: ({ row }) => , }, ] row.id} toolbar={false} /> ``` For interactive cells with anchors or buttons, always test them against row selection, preview, and keyboard navigation. # Local Data Table (https://react-datatable.com/docs/examples/local-data-table) This example shows how common features work together in one local-mode table. You you can copy, run, and then adapt to your own product. It builds a local table with typed rows, stable row IDs, search, filters, display options, selection, bulk actions, keyboard navigation, and preview using static in-file data with no custom cells. For your product, swap the row type, columns, preview content, and bulk actions for your domain. Keep the same discipline around stable IDs, local-mode ownership, and feature layering. Before you start, install the datatable package and set it up. See [Installation](/docs/installation). ## 1. Row type and data [#1-row-type-and-data] Define one `Customer` type and keep the data array in the same file so the example is fully copyable. ```tsx // client type Customer = { id: string name: string company: string status: "Active" | "Trial" | "Paused" plan: "Starter" | "Team" | "Enterprise" seats: number revenue: number owner: string region: "NA" | "EU" | "APAC" renewal: string } const rows: Customer[] = [ { id: "cus_0001", name: "Ava Patel", company: "Northstar Labs", status: "Active", plan: "Enterprise", seats: 185, revenue: 42100, owner: "Jordan Lee", region: "NA", renewal: "2026-09-14" }, { id: "cus_0002", name: "Noah Kim", company: "Blue Harbor Health", status: "Trial", plan: "Team", seats: 42, revenue: 7600, owner: "Marta Rossi", region: "EU", renewal: "2026-06-02" }, { id: "cus_0003", name: "Emma Silva", company: "Orbit Retail Group", status: "Active", plan: "Team", seats: 96, revenue: 18300, owner: "Jordan Lee", region: "NA", renewal: "2026-11-08" }, // ...16 additional rows with the same shape ] ``` This is a good local-mode shape because the browser owns the full dataset and the table can evaluate search, filters, sorting, grouping, and selection without a server query layer. ## 2. Define columns [#2-define-columns] The example columns do more than render labels. They decide filtering, sorting, grouping, and widths with built-in behaviors only. ```tsx // client const columns: DatatableColumn[] = [ { id: "company", header: "Company", accessorKey: "company", width: 250, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "name", header: "Contact", accessorKey: "name", width: 190, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 140, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, { id: "revenue", header: "Revenue", accessorKey: "revenue", width: 130, enableSorting: true, enableFiltering: true, filterType: "number", cell: ({ getValue }) => `$${Number(getValue()).toLocaleString()}`, }, ] ``` The full example also includes `plan`, `seats`, `owner`, `region`, and `renewal` to demonstrate text, list, number, and date filters in one place. ## 3. Mount with stable row IDs [#3-mount-with-stable-row-ids] The core table stays simple: ```tsx // client row.id} initialState={{ sorting: [{ id: "name", desc: false }], showHorizontalLines: false, showVerticalLines: false, }} /> ``` That gives the example a stable contract before any extra interaction layers are added. ## 4. Toolbar [#4-toolbar] The runnable example turns on quick search, filters, and display options from the toolbar. ```tsx // client toolbar={{ quickSearch: { placeholder: "Search 1,200 customers...", }, filterButton: true, displayOptions: true, copyLink: false, views: false, }} ``` This is a good local example mix because it focuses on browsing the current dataset, not URL sharing or saved views. ## 5. Selection and bulk actions [#5-selection-and-bulk-actions] Selection is enabled together with a small bulk-actions registry. ```tsx // client selection={{ enabled: true, mode: "multi", showCheckboxOnHover: false, allowSelectAllMatching: true, }} bulkActions={{ triggerLabel: "Actions", actions: bulkActions, }} ``` The example bulk actions intentionally do lightweight local work: * export selected IDs * mark selected customers as reviewed Each action also clears selection and closes the dialog so the workflow feels complete after the operation finishes. ## 6. Keyboard navigation and preview [#6-keyboard-navigation-and-preview] The example supports both keyboard-first navigation and a floating preview panel. ```tsx // client keyboardNavigation={{ enabled: true, }} rowActions={{ onOpenRow: ({ row }) => { setLastAction(`Opened ${row.company}.`) }, onTogglePreviewRow: ({ row, nextOpen }) => { setLastAction(`${nextOpen ? "Previewing" : "Closed preview for"} ${row.company}.`) }, }} preview={{ floating: { draggable: true, storageKey: "react-datatable-local-basic", }, renderPreview: ({ row }) => ( ), }} ``` This is a good example of the local table acting like a real product surface instead of a bare data grid. ## Full example (single file) [#full-example-single-file]
Expand to copy the full local customer table example ```tsx // client import { useMemo, useState } from "react" import { Datatable, type DataTableBulkAction, type DatatableColumn } from "./react-datatable" type CustomerStatus = "Active" | "Trial" | "Paused" type CustomerPlan = "Starter" | "Team" | "Enterprise" type CustomerRegion = "NA" | "EU" | "APAC" type Customer = { id: string name: string company: string status: CustomerStatus plan: CustomerPlan seats: number revenue: number owner: string region: CustomerRegion renewal: string } const rows: Customer[] = [ { id: "cus_0001", name: "Ava Patel", company: "Northstar Labs", status: "Active", plan: "Enterprise", seats: 185, revenue: 42100, owner: "Jordan Lee", region: "NA", renewal: "2026-09-14" }, { id: "cus_0002", name: "Noah Kim", company: "Blue Harbor Health", status: "Trial", plan: "Team", seats: 42, revenue: 7600, owner: "Marta Rossi", region: "EU", renewal: "2026-06-02" }, { id: "cus_0003", name: "Emma Silva", company: "Orbit Retail Group", status: "Active", plan: "Team", seats: 96, revenue: 18300, owner: "Jordan Lee", region: "NA", renewal: "2026-11-08" }, { id: "cus_0004", name: "Liam Turner", company: "Cinder Logistics", status: "Paused", plan: "Starter", seats: 18, revenue: 2400, owner: "Priya Nair", region: "APAC", renewal: "2026-05-29" }, { id: "cus_0005", name: "Mia Dubois", company: "Kepler Finance", status: "Active", plan: "Enterprise", seats: 220, revenue: 50900, owner: "Marta Rossi", region: "EU", renewal: "2026-12-01" }, { id: "cus_0006", name: "Ethan Brooks", company: "Summit Bio", status: "Trial", plan: "Starter", seats: 24, revenue: 3200, owner: "Diego Alvarez", region: "NA", renewal: "2026-07-10" }, { id: "cus_0007", name: "Sophia Chen", company: "Copperline Energy", status: "Active", plan: "Team", seats: 88, revenue: 15400, owner: "Jordan Lee", region: "EU", renewal: "2026-10-21" }, { id: "cus_0008", name: "Lucas Meyer", company: "Atlas Devices", status: "Paused", plan: "Team", seats: 57, revenue: 9100, owner: "Priya Nair", region: "NA", renewal: "2026-08-03" }, { id: "cus_0009", name: "Isabella Reed", company: "Helio Security", status: "Active", plan: "Enterprise", seats: 164, revenue: 33700, owner: "Diego Alvarez", region: "APAC", renewal: "2026-09-30" }, { id: "cus_0010", name: "James Park", company: "Maple Education", status: "Trial", plan: "Team", seats: 39, revenue: 6900, owner: "Marta Rossi", region: "EU", renewal: "2026-06-27" }, { id: "cus_0011", name: "Charlotte Gray", company: "Riverbend Media", status: "Active", plan: "Starter", seats: 31, revenue: 5100, owner: "Priya Nair", region: "NA", renewal: "2026-10-05" }, { id: "cus_0012", name: "Benjamin Cole", company: "Stonegate Manufacturing", status: "Paused", plan: "Enterprise", seats: 142, revenue: 27100, owner: "Jordan Lee", region: "EU", renewal: "2026-07-19" }, { id: "cus_0013", name: "Amelia Ward", company: "Vista Hospitality", status: "Active", plan: "Team", seats: 73, revenue: 12900, owner: "Diego Alvarez", region: "APAC", renewal: "2026-11-15" }, { id: "cus_0014", name: "Henry Price", company: "Signal Works", status: "Trial", plan: "Starter", seats: 16, revenue: 2100, owner: "Marta Rossi", region: "NA", renewal: "2026-05-18" }, { id: "cus_0015", name: "Harper Quinn", company: "Lumen Foods", status: "Active", plan: "Enterprise", seats: 198, revenue: 44800, owner: "Jordan Lee", region: "EU", renewal: "2026-12-12" }, { id: "cus_0016", name: "Alexander Diaz", company: "Nimbus Telecom", status: "Paused", plan: "Team", seats: 64, revenue: 11200, owner: "Priya Nair", region: "APAC", renewal: "2026-08-24" }, { id: "cus_0017", name: "Evelyn Scott", company: "Forge Mobility", status: "Active", plan: "Team", seats: 104, revenue: 20100, owner: "Diego Alvarez", region: "NA", renewal: "2026-09-07" }, { id: "cus_0018", name: "Michael Evans", company: "Prism Insurance", status: "Trial", plan: "Starter", seats: 22, revenue: 2800, owner: "Marta Rossi", region: "EU", renewal: "2026-06-14" }, { id: "cus_0019", name: "Abigail Hill", company: "Tidal Commerce", status: "Active", plan: "Enterprise", seats: 176, revenue: 39200, owner: "Jordan Lee", region: "NA", renewal: "2026-11-28" }, { id: "cus_0020", name: "Daniel Young", company: "Pioneer Clinics", status: "Paused", plan: "Starter", seats: 28, revenue: 3600, owner: "Priya Nair", region: "APAC", renewal: "2026-07-01" }, ] const statuses: CustomerStatus[] = ["Active", "Trial", "Paused"] const plans: CustomerPlan[] = ["Starter", "Team", "Enterprise"] const regions: CustomerRegion[] = ["NA", "EU", "APAC"] function CustomerPreview({ customer }: { customer: Customer }) { return (

{customer.company}

{customer.name}

Status
{customer.status}
Plan
{customer.plan}
Owner
{customer.owner}
Region
{customer.region}
Seats
{customer.seats}
Revenue
${customer.revenue.toLocaleString()}
Renewal
{customer.renewal}
) } export function LocalCustomerTableExample() { const [lastAction, setLastAction] = useState("Ready: local rows loaded.") const columns = useMemo[]>(() => [ { id: "company", header: "Company", accessorKey: "company", width: 250, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "name", header: "Contact", accessorKey: "name", width: 190, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 130, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, { id: "plan", header: "Plan", accessorKey: "plan", width: 130, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: plans.map((plan) => ({ label: plan, value: plan })), }, }, { id: "region", header: "Region", accessorKey: "region", width: 120, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: regions.map((region) => ({ label: region, value: region })), }, }, { id: "seats", header: "Seats", accessorKey: "seats", width: 110, enableSorting: true, enableFiltering: true, filterType: "number", }, { id: "revenue", header: "Revenue", accessorKey: "revenue", width: 130, enableSorting: true, enableFiltering: true, filterType: "number", cell: ({ getValue }) => `$${Number(getValue()).toLocaleString()}`, }, { id: "owner", header: "Owner", accessorKey: "owner", width: 170, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: Array.from(new Set(rows.map((row) => row.owner))).map((owner) => ({ label: owner, value: owner })), }, }, { id: "renewal", header: "Renewal", accessorKey: "renewal", width: 140, enableSorting: true, enableFiltering: true, filterType: "date", }, ], []) const bulkActions = useMemo[]>(() => [ { id: "mark-reviewed", title: "Mark as reviewed", keywords: ["review", "done"], onSelect: (context) => { setLastAction(`Marked ${context.selectedCount} customer${context.selectedCount === 1 ? "" : "s"} as reviewed.`) context.clearSelection() context.closeDialog() }, }, { id: "export-selection", title: "Export selected IDs", keywords: ["export", "ids"], onSelect: (context) => { const ids = context.selectedRows.map((row) => row.id).join(", ") setLastAction(ids.length > 0 ? `Selected IDs: ${ids}` : "No rows selected.") context.clearSelection() context.closeDialog() }, }, ], []) return (

{lastAction}

row.id} toolbar={{ quickSearch: { placeholder: "Search 20 customers...", }, filterButton: true, displayOptions: true, copyLink: false, views: false, }} initialState={{ sorting: [{ id: "company", desc: false }], showHorizontalLines: false, showVerticalLines: false, }} selection={{ enabled: true, mode: "multi", showCheckboxOnHover: false, allowSelectAllMatching: true, }} bulkActions={{ triggerLabel: "Actions", actions: bulkActions, }} keyboardNavigation={{ enabled: true, }} rowActions={{ onOpenRow: ({ row }) => { setLastAction(`Opened ${row.company}.`) }, onTogglePreviewRow: ({ row, nextOpen }) => { setLastAction(`${nextOpen ? "Previewing" : "Closed preview for"} ${row.company}.`) }, }} preview={{ floating: { draggable: true, storageKey: "docs-local-customer-example", }, renderPreview: ({ row }) => , }} />
) } ```
## Where to go next [#where-to-go-next] * For the setup boundary behind this example, read [Getting started](/docs/quickstart/getting-started). * For the top-level contract decisions, read [Define your table](/docs/guides/define-your-table) and [Define columns](/docs/guides/define-columns). * For a server-owned variant, continue to [Paginated Table](/docs/examples/paginated-table). # Multi-select cell (https://react-datatable.com/docs/examples/multi-select-cell) Use this when one field needs a small set of labels, tags, or categories without expanding into a full editor. ## Build the React component [#build-the-react-component] The main job here is summarizing several values into one compact surface and exposing a small add/remove interaction. ```tsx import { Check, Plus } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" type Tag = { id: string name: string color: string } export function MultiSelectCell({ value, options, onChange, }: { value: Tag[] options: Tag[] onChange: (value: Tag[]) => void }) { const selectedIds = new Set(value.map((tag) => tag.id)) return ( No tags found. {options.map((tag) => { const selected = selectedIds.has(tag.id) return ( { const next = selected ? value.filter((item) => item.id !== tag.id) : [...value, tag] onChange(next) }} value={tag.name} > {selected ? : null} {tag.name} ) })} ) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] The important detail is the accessor: the column still exposes a stable string representation for table semantics even though the UI renders badges from richer objects. ```tsx { id: "tags", header: "Tags", accessorFn: (row) => row.tags.map((tag) => tag.name).join(", "), width: 260, enableSorting: false, enableFiltering: false, cell: ({ row }) => ( updateRow(row.original.id, { tags })} /> ), } ``` ## Render it in a small table [#render-it-in-a-small-table] The table example keeps only the tags column custom so you can see how the badge picker sits inside a normal Datatable. ```tsx function TagsExampleTable() { const [rows, setRows] = useState(seedRows) const updateRow = (id: string, patch: Partial) => { setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row))) } return row.id} toolbar={false} /> } ``` ## Persist changes with optimistic update [#persist-changes-with-optimistic-update] For server sync, treat the selected tags as a small optimistic patch and then refetch the authoritative row. ```tsx async function updateTags(id: string, tags: Tag[]) { setRows((current) => current.map((row) => (row.id === id ? { ...row, tags } : row))) try { await api.customers.updateTags({ id, tagIds: tags.map((tag) => tag.id) }) await refetchCustomers() } catch { await refetchCustomers() } } ``` If the tag UI starts to feel like a whole form, move that workflow into preview or a detail screen instead of overloading the cell. # Paginated Table (https://react-datatable.com/docs/examples/paginated-table) This example keeps fetching and shaping rows on the server by using the datatable in **paginated online** mode, so you get clear page boundaries instead of an infinitely growing list. The live table below behaves like a real product surface: each interaction sends an `OnlineQueryInput` to this site’s demo API, and the table paints whatever rows, totals, and filter metadata come back. When someone opens a multi-select list filter, the response can include **facets** so each choice shows a count that matches the current filters; the field names and shapes are documented under [Online API](/docs/reference/online-api). Swap the column definitions and `query` function for your own route and data layer. Keep stable column IDs, a dataset-level `queryKey`, and backend-owned totals so the footer and filters stay honest. Before you start, install the datatable source into your app and add the server helpers when you need them. See [Installation](/docs/installation) for the `npx @react-datatable/cli install` command and how to use your license token. ## 1. Shared row type [#1-shared-row-type] Define the row shape **once** (for example in `shared/customer.ts` or `contracts/customer.ts`) and import it from both your React table and your API handler. That way the columns, `getRowId`, and `OnlineQueryResponse` stay aligned with the JSON your route actually returns, without duplicating stringly-typed fields. The browser still does not load the full dataset: the API returns one page (plus totals metadata) at a time, scoped by tenant, permissions, and whatever else your handler enforces. ```tsx // shared (import from the same module in your Vite app and in your server process) type Customer = { id: string company: string name: string status: string plan: string region: string seats: number revenue: number owner: string renewal: string } ``` ## 2. Define columns [#2-define-columns] This step is **client-only**: you configure `DatatableColumn` so the table knows headers, widths, and which built-in filter and sort UIs to show. Give every column a stable `id`. When you add the route in **step 7**, reuse those same ids in your server column map so filters and sorts line up with what the table sends. ```tsx // client import type { DatatableColumn } from "./react-datatable" const statuses = ["Active", "Trial", "Paused"] as const const columns: DatatableColumn[] = [ { id: "company", header: "Company", accessorKey: "company", width: 240, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 150, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, ] ``` ## 3. Online pagination [#3-online-pagination] Use `online` instead of `data`. The table owns interaction state; the backend owns matching rows, totals, and facets. ```tsx // client import { Datatable } from "./react-datatable" row.id} online={{ mode: "pagination", queryKey: ["customers", workspaceId], pageSize: 50, supportedGroupingColumns: ["status"], query: fetchCustomersPage, }} initialState={{ sorting: [{ id: "company", desc: false }], }} /> ``` ## 4. Implement the query function [#4-implement-the-query-function] The smallest useful implementation is a `fetch` that posts JSON and returns `OnlineQueryResponse`. The route sketch in step 7.6 uses `runInMemoryDatatableQuery` on every row you load for the tenant (unfiltered within that scope); the helper applies `input` in memory. That is easy to validate but does not scale if the slice is huge, so plan to replace it with a database-backed query while keeping the same route contract. See [Server helper API](/docs/reference/server-helper-api) and [Server query endpoint](/docs/guides/server-query-endpoint). ```tsx // client import type { OnlineQueryInput, OnlineQueryResponse } from "react-datatable-server" async function fetchCustomersPage(input: OnlineQueryInput): Promise> { const response = await fetch("/api/customers/table", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }) if (!response.ok) { throw new Error("Failed to load customers") } return response.json() } ``` ## 5. Add the toolbar and selection configuration [#5-add-the-toolbar-and-selection-configuration] Online mode moves row computation to the server; you can still expose search, filters, display options, selection, and bulk actions on the client. ```tsx // client toolbar={{ quickSearch: { placeholder: "Search Customers..." }, filterButton: true, displayOptions: true, copyLink: false, views: false, }} selection={{ enabled: true, mode: "multi", showCheckboxOnHover: false, allowSelectAllMatching: true, }} ``` ## 6. Viewport virtualization [#6-viewport-virtualization] Pagination decides **which rows the server returns**. Viewport virtualization decides **how many loaded rows the browser mounts**. They compose cleanly: ```tsx // client virtualization={{ mode: "viewport", rowOverscanCount: 12, }} ``` ## 7. Implement the server-side route [#7-implement-the-server-side-route] This walkthrough uses **Hono** and **Drizzle** so you have a concrete, copy-pasteable server file. You can use any web framework and data layer you like; what matters is that your handler accepts `OnlineQueryInput` as JSON and returns `OnlineQueryResponse` for the same row type as the table. Shape that handler like a normal HTTP API route: method, path, JSON body, and JSON response are covered in [Server query endpoint](/docs/guides/server-query-endpoint). Expose one HTTP route (for example `POST /api/customers/table`) on your API process. It should read the JSON body as `OnlineQueryInput` and respond with `OnlineQueryResponse` for the same row type you share with the client in step 1. ### 7.1 Install packages [#71-install-packages] ### 7.2 Open Postgres and create a Drizzle client [#72-open-postgres-and-create-a-drizzle-client] Create one `pg.Pool` from `DATABASE_URL` for the whole process, then pass it to `drizzle`. Reuse that `db` instance from your route handlers. ```ts // server import { drizzle } from "drizzle-orm/node-postgres" import { Pool } from "pg" const pool = new Pool({ connectionString: process.env.DATABASE_URL }) export const db = drizzle(pool) ``` ### 7.3 Model the table with Drizzle [#73-model-the-table-with-drizzle] Add a `pgTable` that matches your real migration: primary key, a tenant or `workspace_id` column for scoping, and every column your `DatatableColumn` definitions read through `accessorKey` or `accessorFn`. ```ts // server/schema/customers.ts (example: grow this to match step 2 and your migrations) import { integer, pgTable, text } from "drizzle-orm/pg-core" export const customers = pgTable("customers", { id: text("id").primaryKey(), workspaceId: text("workspace_id").notNull(), company: text("company").notNull(), contactName: text("contact_name").notNull(), status: text("status").notNull(), plan: text("plan").notNull(), region: text("region").notNull(), seats: integer("seats").notNull(), revenue: integer("revenue").notNull(), owner: text("owner").notNull(), renewal: text("renewal").notNull(), }) ``` ### 7.4 Map SQL rows to your shared row type [#74-map-sql-rows-to-your-shared-row-type] `db.select()` returns rows in the shape of your Drizzle schema. Map each row to the same `Customer` (or product) interface the React table uses (for example, map `contact_name` in SQL to `name` in TypeScript) so the rest of the pipeline works with one type. ```ts // server/map-customer.ts import type { Customer } from "../../shared/customer" import type { customers } from "./schema/customers" type CustomerRow = typeof customers.$inferSelect export function mapRow(row: CustomerRow): Customer { return { id: row.id, company: row.company, name: row.contactName, status: row.status, plan: row.plan, region: row.region, seats: row.seats, revenue: row.revenue, owner: row.owner, renewal: row.renewal, } } ``` ### 7.5 Import the same column definitions as the client [#75-import-the-same-column-definitions-as-the-client] Import the `DatatableColumn[]` from step 2 (in a real repo, move that array to a small shared module and import it from both Vite and the server bundle). The column `id` values are what the table sends in `filters` and `sorting`; your server logic must recognize those ids. ```ts // server/customers-table-route.ts import type { Customer } from "../../shared/customer" import { customerOnlineColumns } from "../../shared/customer-online-columns" // Export `customerOnlineColumns` once from shared/customer-online-columns.ts (the same // array you use in step 2) so filters and sorts hit the same column ids on the server. ``` ### 7.6 Implement `POST /api/customers/table` [#76-implement-post-apicustomerstable] 1. Resolve the signed-in user and `workspaceId` (or equivalent tenant scope). 2. `const input = (await c.req.json()) as OnlineQueryInput`. 3. Load rows for that scope only, for example `await db.select().from(customers).where(eq(customers.workspaceId, workspaceId))`. 4. Map rows with your mapper from 7.4 into `Customer[]`. 5. Turn `input` plus `data` into an `OnlineQueryResponse` (see the snippet below and the note that follows it). Return `c.json(response)` with the correct `content-type` for JSON. ```ts // server/customers-table-route.ts import { Hono } from "hono" import { eq } from "drizzle-orm" import type { OnlineQueryInput } from "react-datatable-server" import { runInMemoryDatatableQuery } from "react-datatable-server" import { customerOnlineColumns } from "../../shared/customer-online-columns" import { customers } from "./schema/customers" import { db } from "./db" import { mapRow } from "./map-customer" export function registerCustomersTableRoute(app: Hono) { app.post("/api/customers/table", async (c) => { const workspaceId = c.req.header("x-workspace-id") ?? "demo_workspace" const input = (await c.req.json()) as OnlineQueryInput const rows = await db.select().from(customers).where(eq(customers.workspaceId, workspaceId)) const data = rows.map(mapRow) const response = await runInMemoryDatatableQuery({ data, columns: customerOnlineColumns, input, getRowId: (row) => row.id, }) return c.json(response) }) } ``` `runInMemoryDatatableQuery` takes the **unfiltered** row array you pass as `data` (for example every mapped customer in the workspace) and applies the online query shape from `input` in process memory: filters, sorts, grouping, pagination, and the other fields the table sends. You do **not** pre-slice `data` to the current page; the helper returns the correct page and metadata in `OnlineQueryResponse`. That makes it a fast way to stand up the route and **verify online mode and column wiring** before you build real SQL. It is **not** a good fit for large datasets, because each request loads the full scoped table (or whatever subset your `where` returns) into server RAM. When behavior looks right in the browser, move the same contract to a database-backed planner or query builder so filtering and paging happen in the engine instead of in Node. [Server query endpoint](/docs/guides/server-query-endpoint) is the place to start for that handoff. ### 7.7 Mount Hono and register the route [#77-mount-hono-and-register-the-route] Create `const app = new Hono()`, call your register function from step 7.6, then export `app` for your runtime (Bun, Node with your adapter, etc.). ```ts // server/app.ts import { Hono } from "hono" import { registerCustomersTableRoute } from "./customers-table-route" const app = new Hono() registerCustomersTableRoute(app) export default app ``` The all-in-one server snippet under **Full examples** inlines the same mapper, columns, and handler in a single file instead of splitting them across modules. The **same handler** also powers [Infinite Scroll Table](/docs/examples/infinite-scroll-table): the client only changes `online.mode` and how it interprets `offset` / `hasMore`; the server still reads `input.mode`, `input.offset`, and `input.limit` from the JSON body. The live table above uses the full showcase columns, row preview, and CSV export wired to this site’s `/api/showcase/export`. The copyable blocks below are a smaller baseline you can paste into your own repository; follow [Server query endpoint](/docs/guides/server-query-endpoint) to implement `POST /api/customers/export` (or your chosen path) for the same `serverExecutor` contract. ## Full examples [#full-examples]
Expand to copy a minimal paginated online table (React) ```tsx // client import { useMemo } from "react" import { Datatable, type DataTableBulkAction, type DataTableBulkServerActionRequest, type DatatableColumn, } from "./react-datatable" import type { OnlineQueryInput, OnlineQueryResponse } from "react-datatable-server" type Customer = { id: string company: string name: string status: "Active" | "Trial" | "Paused" plan: string region: string seats: number revenue: number owner: string renewal: string } const statuses = ["Active", "Trial", "Paused"] as const async function fetchCustomersPage(input: OnlineQueryInput): Promise> { const response = await fetch("/api/customers/table", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }) if (!response.ok) { throw new Error("Failed to load customers") } return response.json() } /** POST /api/customers/export. Same selection payload contract as the bulk-actions guide. */ async function customersBulkServerExecutor(request: DataTableBulkServerActionRequest): Promise { if (request.actionId !== "export-customers-csv") { return } const response = await fetch("/api/customers/export", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ selection: request.selection, payload: request.payload, }), }) if (!response.ok) { throw new Error(`Export failed: ${response.status}`) } const blob = await response.blob() const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = "customers-export.csv" link.click() URL.revokeObjectURL(url) } export function CustomersPaginatedTable({ workspaceId }: { workspaceId: string }) { const columns = useMemo[]>( () => [ { id: "company", header: "Company", accessorKey: "company", width: 240, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 140, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, ], [] ) const bulkActions = useMemo[]>( () => [ { id: "export-selection", title: "Export selected CSV", execution: "server", serverActionId: "export-customers-csv", buildServerPayload: () => ({ requestedBy: "customers-table" }), }, ], [] ) return (
row.id} online={{ mode: "pagination", queryKey: ["customers", workspaceId], pageSize: 50, supportedGroupingColumns: ["status"], query: fetchCustomersPage, }} toolbar={{ quickSearch: { placeholder: "Search Customers..." }, filterButton: true, displayOptions: true, copyLink: false, views: false, }} initialState={{ sorting: [{ id: "company", desc: false }], }} virtualization={{ mode: "viewport", rowOverscanCount: 12 }} selection={{ enabled: true, mode: "multi", showCheckboxOnHover: false, allowSelectAllMatching: true, }} bulkActions={{ triggerLabel: "Actions", actions: bulkActions, serverExecutor: customersBulkServerExecutor, }} />
) } ```
Expand to copy a minimal paginated online table (Hono + Drizzle server) ```ts // server /** * customers-table-api.ts: single-file sketch you can split into schema/, routes/, etc. * Serves POST /api/customers/table for both pagination and infinite online modes. * * Teaching default: `runInMemoryDatatableQuery` runs the online planner on the scoped * row array. Replace that call with SQL-backed planning when the workspace slice is too * large to load whole; keep the same JSON request/response contract. */ import { Hono } from "hono" import { drizzle } from "drizzle-orm/node-postgres" import { eq } from "drizzle-orm" import { Pool } from "pg" import { integer, pgTable, text } from "drizzle-orm/pg-core" import type { OnlineQueryInput } from "react-datatable-server" import { runInMemoryDatatableQuery } from "react-datatable-server" import type { DatatableColumn } from "./react-datatable" // --- Drizzle schema (match your migrations) --- const customers = pgTable("customers", { id: text("id").primaryKey(), workspaceId: text("workspace_id").notNull(), company: text("company").notNull(), contactName: text("contact_name").notNull(), status: text("status").notNull(), plan: text("plan").notNull(), region: text("region").notNull(), seats: integer("seats").notNull(), revenue: integer("revenue").notNull(), owner: text("owner").notNull(), renewal: text("renewal").notNull(), }) type CustomerRow = typeof customers.$inferSelect type Customer = { id: string workspaceId: string company: string name: string status: "Active" | "Trial" | "Paused" plan: string region: string seats: number revenue: number owner: string renewal: string } function mapRow(row: CustomerRow): Customer { return { id: row.id, workspaceId: row.workspaceId, company: row.company, name: row.contactName, status: row.status as Customer["status"], plan: row.plan, region: row.region, seats: row.seats, revenue: row.revenue, owner: row.owner, renewal: row.renewal, } } const statuses = ["Active", "Trial", "Paused"] as const // --- Keep in sync with the React table (extract to shared/ in real apps) --- const customerOnlineColumns: DatatableColumn[] = [ { id: "company", header: "Company", accessorKey: "company", width: 240, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "name", header: "Contact", accessorKey: "name", width: 190, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "status", header: "Status", accessorKey: "status", width: 140, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: statuses.map((status) => ({ label: status, value: status })), }, }, { id: "plan", header: "Plan", accessorKey: "plan", width: 130, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: ["Starter", "Team", "Enterprise"].map((plan) => ({ label: plan, value: plan })), }, }, { id: "region", header: "Region", accessorKey: "region", width: 120, enableSorting: true, enableFiltering: true, enableGrouping: true, filterType: "text-list", filterOptions: { options: ["NA", "EU", "APAC"].map((region) => ({ label: region, value: region })), }, }, { id: "seats", header: "Seats", accessorKey: "seats", width: 110, enableSorting: true, enableFiltering: true, filterType: "number", }, { id: "revenue", header: "Revenue", accessorKey: "revenue", width: 130, enableSorting: true, enableFiltering: true, filterType: "number", }, { id: "owner", header: "Owner", accessorKey: "owner", width: 170, enableSorting: true, enableFiltering: true, filterType: "text", }, { id: "renewal", header: "Renewal", accessorKey: "renewal", width: 140, enableSorting: true, enableFiltering: true, filterType: "date", }, ] const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const db = drizzle(pool) const app = new Hono() app.post("/api/customers/table", async (c) => { // Replace with real session / tenant resolution. const workspaceId = c.req.header("x-workspace-id") ?? "demo_workspace" const input = (await c.req.json()) as OnlineQueryInput const rows = await db.select().from(customers).where(eq(customers.workspaceId, workspaceId)) const data = rows.map(mapRow) const response = await runInMemoryDatatableQuery({ data, columns: customerOnlineColumns, input, getRowId: (row) => row.id, }) return c.json(response) }) export default app ``` For a minimal local run, create the pool from `DATABASE_URL`, start Hono on a port, and point the React `fetch` URL at `http://127.0.0.1:8787/api/customers/table` (or mount this `app` behind your framework’s dev proxy under `/api/customers/table`). When you outgrow the in-memory helper, follow [Server query planning](/docs/guides/server-query-planning) and [Server query execution](/docs/guides/server-query-execution) to push filters, sorts, and pagination into the database.
## Where to go next [#where-to-go-next] * For the data-boundary decision, read [Choose a data mode](/docs/guides/choose-a-data-mode). * For request and response shapes, read [Online API](/docs/reference/online-api). * For continuous loading instead of pages, read [Infinite Scroll Table](/docs/examples/infinite-scroll-table). * For the HTTP contract used by online tables and server bulk payloads, read [Server query endpoint](/docs/guides/server-query-endpoint). # Progress cell (https://react-datatable.com/docs/examples/progress-cell) Use this when a numeric field needs faster visual scanning than plain text can provide. ## Build the React component [#build-the-react-component] This is a pure presentation cell. It turns one numeric value into a compact bar plus label and stays very cheap to render. ```tsx export function ProgressCell({ value }: { value: number }) { const tone = value >= 70 ? "bg-sky-500" : value >= 40 ? "bg-amber-500" : "bg-red-500" return ( {value} ) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] Because the value is still just a number, the column contract stays extremely simple. ```tsx { id: "health", header: "Health", accessorKey: "health", width: 160, enableSorting: false, enableFiltering: false, cell: ({ row }) => , } ``` ## Render it in a small table [#render-it-in-a-small-table] The table demo shows the progress bar beside plain contact fields so the visual scan benefit is easy to feel. ```tsx const columns: DatatableColumn[] = [ { id: "name", header: "Contact", accessorKey: "name" }, { id: "company", header: "Company", accessorKey: "company" }, { id: "health", header: "Health", accessorKey: "health", cell: ({ row }) => , }, ] row.id} toolbar={false} /> ``` Progress cells are strongest when they stay dumb and cheap: one value in, one compact visual out. # Single select cell (https://react-datatable.com/docs/examples/single-select-cell) Use this for compact states like status, stage, or lifecycle where a small dropdown is enough. ## Build the React component [#build-the-react-component] This is a good example of a cell that feels product-specific while still being ordinary React code. ```tsx import { Check } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" const toneByStatus = { Active: "#10b981", Trial: "#8b5cf6", Paused: "#f59e0b", } const statuses = ["Active", "Trial", "Paused"] as const type Status = (typeof statuses)[number] export function SingleSelectCell({ value, onChange, }: { value: Status onChange: (value: Status) => void }) { return ( {statuses.map((status) => ( onChange(status)}> {status} {status === value ? ))} ) } ``` ## Wire it into a column definition [#wire-it-into-a-column-definition] Keep the finite state field anchored to the column contract, then let the cell own the compact dropdown treatment. ```tsx { id: "status", header: "Status", accessorKey: "status", width: 140, enableSorting: false, enableFiltering: false, cell: ({ row }) => ( updateRow(row.original.id, { status })} /> ), } ``` ## Render it in a small table [#render-it-in-a-small-table] The demo table uses one custom status column and plain surrounding columns so the pattern stays easy to scan. ```tsx function StatusExampleTable() { const [rows, setRows] = useState(seedRows) const updateRow = (id: string, patch: Partial) => { setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row))) } return row.id} toolbar={false} /> } ``` ## Persist changes with optimistic update [#persist-changes-with-optimistic-update] If status changes should sync to a backend, optimistic update first and let the server remain authoritative after the mutation settles. ```tsx async function updateStatus(id: string, status: Status) { setRows((current) => current.map((row) => (row.id === id ? { ...row, status } : row))) try { await api.customers.updateStatus({ id, status }) await refetchCustomers() } catch { await refetchCustomers() } } ``` Good single-select cells stay visually small, keyboard-friendly, and honest about the underlying field they are editing. # Add bulk actions (https://react-datatable.com/docs/guides/add-bulk-actions) Use this guide when selected rows should trigger real work. ## Start from a clear selection model [#start-from-a-clear-selection-model] Bulk actions should sit on top of a selection model users already understand. Add them after you have: * a table with `selection.enabled` * clear distinction between selected rows, active row, and preview state * at least one real multi-row task to support If the product still cannot answer "what should happen after these rows are selected?", do not add a bulk action menu yet. ## Add the bulk actions config beside selection [#add-the-bulk-actions-config-beside-selection] Bulk actions are enabled with the `bulkActions` prop. ```tsx row.id} selection={{ enabled: true, allowSelectAllMatching: true, }} bulkActions={{ triggerLabel: "Actions", actions, serverExecutor: async (request) => { await fetch("/api/customers/bulk", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request), }) }, }} /> ``` The action island only appears when there is at least one selected row. > \[!NOTE] > Screenshot placeholder: bulk action island visible above the table with selected rows, a clear selected count, and action wording that makes scope obvious. ## Action labels [#action-labels] Users should be able to predict the outcome from the action title alone. Good labels include: * `Archive selected rows` * `Assign selected customers` * `Export all matching accounts` Avoid vague labels like `Run`, `Apply`, or `Continue`. That matters even more when selection might represent the whole filtered result instead of only visible rows. ## Use client actions for lightweight local work [#use-client-actions-for-lightweight-local-work] Client actions are a good fit when the selected rows already exist in memory, the action only needs the loaded row data, and the work stays inside the current session. ```tsx const actions = [ { id: "copy-emails", title: "Copy emails", onSelect: async ({ selectedRows, selectedCount, clearSelection, closeDialog }) => { await navigator.clipboard.writeText(selectedRows.map((row) => row.email).join("\n")) toast.success(`Copied ${selectedCount} email${selectedCount === 1 ? "" : "s"}.`) clearSelection() closeDialog() }, }, ] ``` Use this path for things like: * copying data to the clipboard * opening a local confirm step * updating app state that does not require a backend round trip If the action mutates protected data, sends notifications, exports large datasets, or needs audit logs, move it to the server path instead. ## Use server actions when the backend owns the outcome [#use-server-actions-when-the-backend-owns-the-outcome] Server actions are the safer default for durable work. ```tsx const actions = [ { id: "export", title: "Export customers CSV", execution: "server", serverActionId: "export-customers-csv", buildServerPayload: ({ selectedCount }) => ({ requestedCount: selectedCount, requestedBy: currentUser.id, }), }, ] ``` The table passes a `DataTableBulkServerActionRequest` to `serverExecutor`. ```ts type DataTableBulkServerActionRequest = { actionId: string selection: DataTableSelectionDescriptor payload?: unknown } ``` That request shape is what lets your backend distinguish explicit row IDs from an all-matching selection built from the current query. ## Add confirmation steps for destructive or expensive actions [#add-confirmation-steps-for-destructive-or-expensive-actions] If an action deletes, archives, reassigns, bills, or launches a long-running job, give users a deliberate confirm step. ```tsx { id: "archive", title: "Archive customers", getInitialStep: () => ({ kind: "confirm", title: "Archive selected customers?", description: "Archived customers leave the active workspace view.", confirmLabel: "Archive", onConfirm: async ({ selectedRows, clearSelection, closeDialog }) => { await archiveCustomers(selectedRows) clearSelection() closeDialog() }, }), } ``` A strong confirmation step restates: * what action will happen * how many rows are affected * whether the scope is selected rows or all matching rows * any permission or irreversibility warning > \[!NOTE] > Screenshot placeholder: destructive bulk-action confirm dialog showing exact row count, action wording, and a clear warning about irreversible scope. ## Make all-matching scope explicit [#make-all-matching-scope-explicit] When `allowSelectAllMatching` is enabled, the selected set may include rows that are not currently loaded in the browser. That means your action labels, confirm step, and backend contract all need to say what will actually happen. ```ts type DataTableSelectionDescriptor = | { kind: "explicit" ids: string[] } | { kind: "allMatching" query: OnlineQueryStateInput includedIds: string[] excludedIds: string[] totalMatchingRows: number } ``` For `allMatching`, the backend should recompute the target rows from the query and then apply any exclusions. ```ts export async function runBulkAction(request: DataTableBulkServerActionRequest) { const targetIds = request.selection.kind === "explicit" ? request.selection.ids : await resolveMatchingIds(db, request.selection.query, request.selection.excludedIds) if (request.actionId === "customers.archive") { await db.update(customers).set({ archivedAt: new Date() }).where(inArray(customers.id, targetIds)) } } ``` Do not assume the browser will send every matching row ID for large online tables. ## Nested steps [#nested-steps] Bulk actions can open an `items`, `confirm`, or `custom` step. That is useful when one entry point should fan out into several related operations. ```tsx const actions = [ { id: "assign", title: "Assign…", getInitialStep: () => ({ kind: "items", title: "Assign selected accounts", searchPlaceholder: "Find owner…", items: owners.map((owner) => ({ id: owner.id, title: owner.name, execution: "server", serverActionId: "accounts.assign-owner", buildServerPayload: () => ({ ownerId: owner.id }), })), }), }, { id: "archive", title: "Archive…", getInitialStep: () => ({ kind: "confirm", title: "Archive selected accounts?", description: "Archived accounts leave the active pipeline.", confirmLabel: "Archive", execution: "server", serverActionId: "accounts.archive", buildServerPayload: () => ({ archiveReason: "user_requested" }), onConfirm: async () => {}, }), }, { id: "export", title: "Export…", getInitialStep: () => ({ kind: "custom", title: "Choose export format", render: ({ executeServerAction, closeDialog }) => (
), }), }, ] ``` Use: * `items` for a short searchable list of choices * `confirm` for one deliberate yes/no step * `custom` when you need a tiny piece of app-owned UI such as format buttons or a date picker Keep the step flow short. If the action becomes a full workflow with complex validation, it probably belongs in app-owned UI instead. > \[!NOTE] > Screenshot placeholder: bulk-action menu branching into a short secondary choice list such as “Assign…” or “Move to status…”, showing auxiliary step UI beyond the first action island. ## Clear selection after completion [#clear-selection-after-completion] The built-in dialog clears selection and closes automatically after successful server execution. For client actions, you control that behavior yourself with `clearSelection()` and `closeDialog()`. Clear selection when completion means the working set is done. Keep selection intact when users are likely to retry, compare outcomes, or run a second action immediately after an error. ## Errors and auditability [#errors-and-auditability] For server-backed actions, your app should own: * permission checks * retry behavior * long-running job status * audit logs * success and failure messaging near the table or in surrounding product UI The table gives you the selection contract and command surface. Your app still owns operational safety. ## Verify bulk actions before you move on [#verify-bulk-actions-before-you-move-on] Before you continue, confirm that: * each action corresponds to a real multi-row task * labels and confirmation text make scope obvious * destructive or expensive actions require deliberate confirmation * client actions only handle work that is safe to do locally * server actions receive enough payload to audit the request * all-matching selections are resolved server-side from query plus exclusions * success and error behavior leave users confident about what happened See [Server query endpoint](/docs/guides/server-query-endpoint) for the HTTP contract used by server-backed bulk actions alongside online table requests. If selection still needs work, go back to [Add row selection](/docs/guides/add-row-selection). # Add column filters (https://react-datatable.com/docs/guides/add-column-filters) Use this guide when users need more precision than one global search box can provide. The goal is to make the right columns filterable, pick filter types that match the data, and keep the built-in filter UI predictable for both users and app developers. The filter UI can look similar in local and online mode. The important difference is where the filter payloads are actually applied. ## Start with the columns users actually filter by [#start-with-the-columns-users-actually-filter-by] Column filters work best for fields that answer a clear question: * status * owner * priority * plan * renewal date * seat count or revenue band Do not add filters to every column by default. A table with too many low-value filters becomes harder to scan and harder to trust. ## Mark each filterable column with a `filterType` [#mark-each-filterable-column-with-a-filtertype] A column needs a `filterType` before it can participate in the built-in filter UI. ```tsx const columns: DatatableColumn[] = [ { id: "company", accessorKey: "company", header: "Company", filterType: "text", }, { id: "status", accessorKey: "status", header: "Status", filterType: "text-list", filterOptions: { options: [ { value: "active", label: "Active" }, { value: "trial", label: "Trial" }, { value: "paused", label: "Paused" }, ], }, }, { id: "renewalDate", accessorKey: "renewalDate", header: "Renewal", filterType: "date", }, ] ``` If a column should never be filterable, omit `filterType` or set `enableFiltering: false`. ## Turn on the toolbar filter entry point [#turn-on-the-toolbar-filter-entry-point] Enable the filter button so users can open the built-in filter picker. ```tsx row.id} toolbar={{ filterButton: true, appliedState: { showFilters: true, }, }} /> ``` The current toolbar shows active filter chips when `appliedState.showFilters` is enabled. ## Pick the right filter type for each field [#pick-the-right-filter-type-for-each-field] Use the built-in filter types according to the kind of question users ask. ### Use `text` for open-ended string matching [#use-text-for-open-ended-string-matching] Choose `text` when users search a field by partial or exact text. ```tsx { id: "company", accessorKey: "company", header: "Company", filterType: "text", } ``` The current docs and source support text-filter modes such as contains, equals, starts with, ends with, and excludes. ### Use `text-list` for finite labels [#use-text-list-for-finite-labels] Choose `text-list` when the field has a bounded set of meaningful values. ```tsx { id: "status", accessorKey: "status", header: "Status", filterType: "text-list", filterOptions: { options: [ { value: "active", label: "Active" }, { value: "trial", label: "Trial" }, { value: "paused", label: "Paused" }, ], }, } ``` This is usually the best choice for status, priority, plan, stage, or other user-facing facets. You can also customize how option-list filters render without changing the filter payload. This is useful when an option needs a visual cue, such as an owner avatar, status dot, or boolean badge. ```tsx { id: "owner", header: "Owner", accessorFn: (row) => row.owner.name, filterType: "text-list", filterOptions: { options: owners.map((owner) => ({ value: owner.name, label: owner.name })), renderOption: (option) => { const owner = owners.find((item) => item.name === option.value) return ( <> {owner ? : null} {option.label} ) }, }, } ``` `renderOption` is presentation-only. The active filter still stores and applies the selected `value`. ### Use `number` for measurable values [#use-number-for-measurable-values] Choose `number` for counts, prices, scores, durations, or quantities. ```tsx { id: "seats", accessorKey: "seats", header: "Seats", filterType: "number", } ``` ### Use `date` for date-oriented questions [#use-date-for-date-oriented-questions] Choose `date` when users think in before/after/range language. ```tsx { id: "renewalDate", accessorKey: "renewalDate", header: "Renewal", filterType: "date", } ``` Normalize date values before they reach the table so the UI and backend agree on what each filter means. ### Use `boolean` for yes/no fields [#use-boolean-for-yesno-fields] Choose `boolean` when the column answers a binary question such as active vs inactive, paid vs unpaid, or internal vs external. ```tsx { id: "isActive", accessorKey: "isActive", header: "Active", filterType: "boolean", } ``` By default the built-in editor shows `True` and `False`. You can override those labels with `filterOptions.options`. ```tsx { id: "isInternal", accessorKey: "isInternal", header: "Audience", filterType: "boolean", filterOptions: { options: [ { value: true, label: "Internal" }, { value: false, label: "External" }, ], renderOption: (option) => ( <>