loading

v33.5.0

select option

useTableVirtualizer hook

Virtualize NewTable for better performance

useTableVirtualizer is a headless utility for virtualizing instances of NewTable. It generates the necessary props in order to fully virtualize instances of NewTable with minimal extra code required.

Demo

Demo table with 10.000 rows. See it in Playroom

Core concepts

Virtualization is the process of selectively rendering a portion of the visible user interface to avoid the performance cost of rendering elements that will not be visible to the user.

For NewTable, this is achieved using two separate tools that work together: the useTableVirtualizer hook, and the NewTable.VirtualWrapper component.

Compatibility & support

While non-virtualized tables can have arbitrary levels of twiddle depth, at the moment we are only able to support one level of nesting in virtualized tables.

All ink components should be compatible to be rendered within a virtualized table, except for NewTable.OrderableRow.

Frozen columns

Tables with frozen columns (cells with freeze={true}) only make sense in tables which overflow along the x-axis (i.e., horizontal scrolling). To ensure your table is able to scroll horizontally, use column width measurements that add up to more than the width of your container, or more than 100%.

Required props

NameFunctionTypeWhenWhy
height<NewTable.VirtualWrapper />CSS WidthAlways
width<NewTable.Cell /><NewTable.HeadCell />CSS HeightAlways
densityuseTableVirtualizerNewTable docsSometimes

Mixing relative and absolute widths

If horizontal scrolling is not a problem for you, or if you were already planning to have a horizontal scroll bar (for example, along with frozen columns), you can skip this section.

Sometimes it is desirable to mix table columns measured as a fixed px value, and columns that take up proportional fractions of the remaining space. Also, NewTable.Cell instances with presets "checkbox" or "twiddleIcon" have a built-in width of 40px.

For example, let's take a table with a checkbox column, an 80px wide ID column, and three columns using up the remainder (40%/30%/30% each). This works out of the box if we're not using virtualization:

Demo

IDTypeNameSecurity
1234OptionTagg PalmerES-123

Code

<NewTable.Row>
<NewTable.Cell preset="checkbox">
<NewCheckbox />
</NewTable.Cell>
<NewTable.Cell width="80px">1234</NewTable.Cell>
<NewTable.Cell width="40%">Option</NewTable.Cell>
<NewTable.Cell width="30%">Tagg Palmer</NewTable.Cell>
<NewTable.Cell width="30%">ES-123</NewTable.Cell>
</NewTable.Row>

However, something looks odd as soon as we add NewTable.VirtualWrapper: the table now has a horizontal scrollbar.

Demo

IDTypeNameSecurity
1234OptionTagg PalmerES-123

Reasoning

For tables with a small number of columns, the presence of a horizontal scrollbar might be undesirable. This happens because, as virtualized table rows have to be relatively positioned on the table, they can't access the layout context of the table, and the browser is unable to subtract the px-width columns from the percentage.

(This is the same reason why every cell needs a width.)

Solution

If horizontal scrolling is a problem, we can use the knowledge that our fixed width columns add up to 40px + 80px = 120px, and then use the CSS calc() function to calculate a percentage of the remainder:

calc((100% - {sum_of_widths_in_pixels}px) * {percentage_of_remaining_width})
// i.e.:
calc((100% - 120px) * 0.3) -> 30%
calc((100% - 120px) * 0.4) -> 40%

Updated demo

IDTypeNameSecurity
1234OptionTagg PalmerES-123

Code

<NewTable.Row>
{/* preset="checkbox" is 40px by default */}
<NewTable.HeadCell preset="checkbox" />
{/* manually set width of 80px */}
<NewTable.HeadCell width="80px" />
{/* the prior HeadCells sum up to 120px so to calculate the */}
{/* the remaining available width, we can use (100% - 120px) */}
<NewTable.HeadCell width="calc((100% - 120px) * 0.4)" />
<NewTable.HeadCell width="calc((100% - 120px) * 0.3)" />
<NewTable.HeadCell width="calc((100% - 120px) * 0.3)" />
</NewTable.Row>

Be aware that the percentage values must add up to 100% (or 1). In the example above, inadvertently using 0.3 as the width for all columns would've resulted in using 0.3 + 0.3 + 0.3 = 0.9, which is less than 1, resulting in an empty space next to the rightmost column (the remaining 0.1, or 10%):

Demo

IDTypeNameSecurity
1234OptionTagg PalmerES-123
Abstracting the generation of the calc() expression for more complicated measuring unit combinations is left as an exercise for the reader.

You can also use this technique to define column width as fractions by dividing by the number of columns of equal width:

Code

<NewTable.Row>
<NewTable.HeadCell preset="checkbox" />
<NewTable.HeadCell width="80px" />
<NewTable.HeadCell width="calc((100% - 120px) / 3)" />
<NewTable.HeadCell width="calc((100% - 120px) / 3)" />
<NewTable.HeadCell width="calc((100% - 120px) / 3)" />
</NewTable.Row>
Tip: assigning the width values for the different columns to an object and referencing that object in both the head and body props will make adjusting these widths a much less frustrating experience.

Twiddle rows

To use twiddle rows, you need to provide the virtualizer with two callbacks:

  • getTwiddleRowCount: called with one element from your data array and returns the number of children this element would have if expanded.
  • getItemKey: called with an index number and returns a string | number which uniquely identifies the object belonging to that row.

It is advisable to wrap these functions in React.useCallback to avoid unnecessary renders.

const virtualizer = useTableVirtualizer(users, {
getItemKey: React.useCallback((index) => users[index].id, [users]),
getTwiddleRowCount: React.useCallback((user) => user.accounts.length, []),
})

Providing these functions will add two additional attributes to each virtualRow instance, which can be used to identify which row you're currently at during the rendering cycle.

type ParentTwiddle = { type: "parent" };
type ChildTwiddle = { type: "child"; twiddleIndex: number }

For more details, see 4. Adding Twiddle rows.

Tutorial

In order to virtualize an existing instance of NewTable, perform the following steps. If you'd like to follow along, you can get started in Playroom by clicking here.

This guide assumes your table renders one row per item in an array of objects called data - adjust accordingly.

Playroom tips

  • When editing code in Playroom, you can press Ctrl/Cmd + S to format it with prettier, which will also tell you if/where you have a syntax error.
  • The diff coordinate markers (@@ Playroom:xx @@) should be helpful to the starting line for the code changes.
  • The diff snippets below are made to facilitate copying and pasting, and won't include diff markers.
Note: this tutorial uses @ngneat/falso to generate fake data.

1. Set fixed column widths

For each table cell element (NewTable.HeadCell and NewTable.Cell), give it a fixed width represented as a percentage. Make sure widths add up to 100%. For more information on why this is necessary, check out Core concepts#Mixing relative and absolute widths.

@@ Playroom:36 @@
<NewTable.Head>
<NewTable.Row>
- <NewTable.HeadCell>ID</NewTable.HeadCell>
- <NewTable.HeadCell>Stakeholder</NewTable.HeadCell>
- <NewTable.HeadCell>Security</NewTable.HeadCell>
+ <NewTable.HeadCell width="20%">ID</NewTable.HeadCell>
+ <NewTable.HeadCell width="40%">Stakeholder</NewTable.HeadCell>
+ <NewTable.HeadCell width="40%">Security</NewTable.HeadCell>
</NewTable.Row>
</NewTable.Head>
<NewTable.Body>
{data.map(stakeholder => (
<NewTable.Row>
- <NewTable.Cell>
+ <NewTable.Cell width="20%">
<Text variant="monospace">{stakeholder.id}</Text>
</NewTable.Cell>
- <NewTable.Cell>{stakeholder.name}</NewTable.Cell>
- <NewTable.Cell>{stakeholder.security}</NewTable.Cell>
+ <NewTable.Cell width="40%">{stakeholder.name}</NewTable.Cell>
+ <NewTable.Cell width="40%">{stakeholder.security}</NewTable.Cell>
</NewTable.Row>
))}
</NewTable.Body>

2. Hooking things up

The most important tool coming from the virtualizer is the function virtualizer.getVirtualItems(), which returns an array of VirtualRows representing the rows currently visible on the table that need to be rendered. By iterating through that array with .map(), we can call the virtualizer's getRowItem method and retrieve that row's corresponding data array element.

Back to the tutorial: instantiate useVirtualizer and wrap NewTable with NewTable.VirtualWrapper. If your NewTable already had a height prop, move the prop to VirtualWrapper. If not, you'll have to set one, as virtualization relies on the presence of a scrolling container.

NewTable.VirtualWrapper is the container and context provider that will allow NewTable and its child components to adjust their styles for virtualized rendering.
@@ Playroom:32 @@
}, [TABLE_ROWS_TO_GENERATE]);
+
+ const virtualizer = useTableVirtualizer(data);
return (
+ <NewTable.VirtualWrapper height="540px">
- <NewTable height="540px">
+ <NewTable>
<NewTable.Head>
@@ Playroom:56 @@
</NewTable>
+ </NewTable.VirtualWrapper>
)

3. Using props getters

At this stage, you'll start getting runtime exceptions thrown from some of NewTable's child components. We'll address those in the next step!

For more information on the other attributes of Virtualizer, see the API#Virtualizer section.

@@ Playroom:35 @@
return (
- <NewTable.VirtualWrapper height="540px">
+ <NewTable.VirtualWrapper {...virtualizer.getWrapperProps()} height="540px">
<NewTable>
@@ Playroom:44 @@
</NewTable.Head>
- <NewTable.Body>
- {data.map(stakeholder => (
- <NewTable.Row>
+ <NewTable.Body {...virtualizer.getBodyProps()}>
+ {virtualizer.getVirtualItems().map(virtualRow => {
+ const stakeholder = virtualizer.getRowItem(virtualRow);
+ return (
+ <NewTable.Row {...virtualizer.getRowProps(virtualRow)}>
<NewTable.Cell width="20%">
@@ Playroom:55 @@
</NewTable.Row>
- ))}
+ );
+ })}
</NewTable.Body>
</NewTable>

We can see our table has about 10 rows visible at any given time, so we can make sure that we render at least one more page in either direction to make sure users have a smoother scrolling experience:

@@ Playroom:32 @@
- const virtualizer = useTableVirtualizer(data);
+ const virtualizer = useTableVirtualizer(data, { overscan: 10 })

By this point, your table will be fully virtualized, and its first-paint and re-rendering performance will have improved drastically. If you'd like to check your work, or if you're only interested in the Twiddle tutorial, you can check the Playroom here.

4. Adding Twiddle rows

Following the instructions from back in Core concepts#Twiddle rows, we'll get started by providing the necessary callbacks. If you've been following along with the Playroom, you might've noticed that the data array we're using has an extra attribute: transactions. Each transaction includes an UUID, a transaction type, currency, and amount; and each user can have anywhere between 1 and 5 transactions.

@@ Playroom:32 @@
- const virtualizer = useTableVirtualizer(data, { overscan: 10 });
+ const virtualizer = useTableVirtualizer(data, {
+ overscan: 10,
+ getItemKey: React.useCallback(index => data[index].id, [data]),
+ getTwiddleRowCount: React.useCallback(item => item.transactions.length, []),
+ })

We'll first conditionally render the parent row, and wrap it with NewTable.Twiddle. Then we'll add a new cell to each row, containing the twiddle row expand/collapse button:

To keep track of each row's open or closed state, each Twiddle row needs an ID to register itself with the Twiddle context. You can provide this ID as a prop to NewTable.Twiddle, which will be more performant than the default behavior of generating a UUID per row.
@@ Playroom:51 @@
const stakeholder = virtualizer.getRowItem(virtualRow);
+ if (virtualRow.type === 'parent') {
return (
+ <NewTable.Twiddle id={stakeholder.id} key={virtualRow.key}>
+ {({ toggle }) => (
<NewTable.Row {...virtualizer.getRowProps(virtualRow)}>
+ <NewTable.Cell preset="twiddleIcon">
+ <NewTable.Twiddle.Icon onClick={toggle} />
+ </NewTable.Cell>
<NewTable.Cell width="20%">
@@ Playroom:65 @@
</NewTable.Row>
+ )}
+ </NewTable.Twiddle>
);
+ }
})}

And then we'll render the correct child row, retrieving each transaction based on its index from virtualRow.twiddleIndex:

@@ Playroom:69 @@
</NewTable.Twiddle>
);
}
+
+ const txn = stakeholder.transactions[virtualRow.twiddleIndex];
+ return (
+ <NewTable.Twiddle id={txn.id} key={virtualRow.key}>
+ <NewTable.Row {...virtualizer.getRowProps(virtualRow)}>
+ <NewTable.Cell width="20%">
+ <Text variant="monospace">{txn.id}</Text>
+ </NewTable.Cell>
+ <NewTable.Cell width="40%">
+ {txn.type.charAt(0).toUpperCase() + txn.type.slice(1)}
+ </NewTable.Cell>
+ <NewTable.Cell width="40%">
+ <Currency format="code" code={txn.currency} value={txn.amount} colorize />
+ </NewTable.Cell>
+ </NewTable.Row>
+ </NewTable.Twiddle>
+ );
})}
</NewTable.Body>

We also need to add the "Expand/Collapse All" button in the header, and a placeholder cell in the child to make sure everything aligns:

@@ Playroom:41 @@
<NewTable.Head>
<NewTable.Row>
+ <NewTable.HeadCell preset="twiddleIcon">
+ <NewTable.Twiddle.ExpandAll />
+ </NewTable.HeadCell>
<NewTable.HeadCell width="20%">ID</NewTable.HeadCell>
<NewTable.HeadCell width="40%">Stakeholder</NewTable.HeadCell>
@@ Playroom:72 @@
<NewTable.Twiddle id={txn.id} key={virtualRow.key}>
<NewTable.Row {...virtualizer.getRowProps(virtualRow)}>
+ {/* empty placeholder cell */}
+ <NewTable.Cell preset="twiddleIcon" />
<NewTable.Cell width="20%">
<Text variant="monospace">{txn.id}</Text>

5. Design adjustments

At this point, our twiddle rows are completely functional, but require minor design adjustments for better alignment:

  • We'll use the tips from Core concepts#Mixing relative and absolute widths to take the 40px twiddle button column into account.
  • We'll create an object to hold the width values so we don't have to repeat them as many times.
  • We'll make the ID column wide enough to fit both short and long IDs, and split the remaining space in half.
@@ Playroom:37 @@
getTwiddleRowCount: React.useCallback(item => item.transactions.length, []),
});
+ const widths = {
+ id: '317px',
+ other: 'calc((100% - 357px) * 0.5)',
+ };
return (
@@ Playroom:50 @@
<NewTable.Twiddle.ExpandAll />
</NewTable.HeadCell>
- <NewTable.HeadCell width="20%">ID</NewTable.HeadCell>
- <NewTable.HeadCell width="40%">Stakeholder</NewTable.HeadCell>
- <NewTable.HeadCell width="40%">Security</NewTable.HeadCell>
+ <NewTable.HeadCell width={widths.id}>ID</NewTable.HeadCell>
+ <NewTable.HeadCell width={widths.other}>Name</NewTable.HeadCell>
+ <NewTable.HeadCell width={widths.other}>Security</NewTable.HeadCell>
</NewTable.Row>
</NewTable.Head>
@@ Playroom:66 @@
<NewTable.Twiddle.Icon onClick={toggle} />
</NewTable.Cell>
- <NewTable.Cell width="20%">
+ <NewTable.Cell width={width.id}>
<Text variant="monospace">{stakeholder.id}</Text>
</NewTable.Cell>
- <NewTable.Cell width="40%">{stakeholder.name}</NewTable.Cell>
- <NewTable.Cell width="40%">{stakeholder.security}</NewTable.Cell>
+ <NewTable.Cell width={widths.other}>{stakeholder.name}</NewTable.Cell>
+ <NewTable.Cell width={widths.other}>{stakeholder.security}</NewTable.Cell>
</NewTable.Row>
)}
@@ Playroom:83 @@
{/* empty placeholder cell */}
<NewTable.Cell preset="twiddleIcon" />
- <NewTable.Cell width="20%">
+ <NewTable.Cell width={widths.id}>
<Text variant="monospace">{txn.id}</Text>
</NewTable.Cell>
- <NewTable.Cell width="40%">
+ <NewTable.Cell width={widths.other}>
{txn.type.charAt(0).toUpperCase() + txn.type.slice(1)}
</NewTable.Cell>
- <NewTable.Cell width="40%">
+ <NewTable.Cell width={widths.other}>
<Currency format="code" code={txn.currency} value={txn.amount} colorize />
</NewTable.Cell>

6. Demo

IDNameSecurity

API

DataItem corresponds to each individual element in your data array, which should be provided as the first argument to useTableVirtualizer.

interface VirtualizerOptions<DataItem> extends TanstackVirtualOptions {
density?: NewTableProps["density"];
getTwiddleRowCount?: (dataItem: DataItem) => number;
}
type UseTableVirtualizer = <DataItem>(
data: DataItem[],
virtualizerOptions?: VirtualizerOptions<DataItem> | undefined
) => {
/** Returns an array of virtual items corresponding to the currently visible rows. */
getVirtualItems: () => VirtualRow[];
/** Call and destructure its returned object on <NewTable.VirtualWrapper>. */
getWrapperProps: () => VirtualWrapperProps;
/** Call and destructure its returned object on <NewTable.Head>. */
getBodyProps: () => NewTableBodyProps;
/** Pass a virtualRow and destructure its returned object on each <NewTable.Row> */
getRowProps: (virtualRow: VirtualRow) => NewTableRowProps;
/** Pass a virtualRow to retrieve the virtual row's corresponding data item. */
getRowItem: (virtualRow: VirtualRow) => DataItem;
}

Virtualizer

The virtualizer return value from useTableVirtualizer extends the Virtualizer object from @tanstack/react-virtual, adding the following ink-specific methods:

MethodDescription
getWrapperPropsCall and destructure its returned object on <NewTable.VirtualWrapper>.
getBodyPropsCall and destructure its returned object on <NewTable.Head>.
getRowPropsPass a virtualRow and destructure its returned object on each <NewTable.Row>.
getRowItemPass a virtualRow to retrieve the virtual row's corresponding data item.

useTableVirtualizer is built with @tanstack/react-virtual and supports most of its arguments as a second (optional) positional argument, with the exception of the following (as they're provided by us):

  • count
  • estimateSize
  • getScrollElement

Extra options

The following options come from the @tanstack/react-virtual API directly and are not required, but might be useful:

overscan

Sets the number of rows to render off-screen. This is useful if your table rows are "popping in" while scrolling. Developers should experiment with different values. Make note of how many rows are visible at any given time, and set a number between 0.5x and 1.5x that amount to start.

The default value for overscan is 5.

See the overscan docs here.

initialRect and observeElementRect

These options are useful mostly in testing and SSR environments. The JSDom environment where Jest/React Testing Library tests run does not support DOM features that the virtualizer depends on. This will cause your scrolling container (NewTable.Body) to report a height of 0 unless a pair of { width, height } dimensions is provided as the initialRect argument, and/or as a value to the observeElementRect callback. For a practical example, see the useTableVirtualizer tests here.

See the initialRect docs here and the observeElementRect docs here.

Known issues

  • Virtualized tables without a <NewTable.Head> and only a <NewTable.Body> are not currently measured correctly by the virtualizer and throw a "Maximum update exceeded" error. Check out this Playroom for a reproduction and guidance on different ways to solve it.

Is this page helpful?