Tools
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.
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.
All ink components should be compatible to be rendered within a virtualized table, except for NewTable.OrderableRow.
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%.
Name | Function | Type | When | Why |
---|---|---|---|---|
height | <NewTable.VirtualWrapper /> | CSS Width | Always | |
width | <NewTable.Cell /> <NewTable.HeadCell /> | CSS Height | Always | |
density | useTableVirtualizer | NewTable docs | Sometimes |
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:
ID | Type | Name | Security | |
---|---|---|---|---|
1234 | Option | Tagg Palmer | ES-123 |
<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.
ID | Type | Name | Security | |
---|---|---|---|---|
1234 | Option | Tagg Palmer | ES-123 |
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.)
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%
ID | Type | Name | Security | |
---|---|---|---|---|
1234 | Option | Tagg Palmer | ES-123 |
<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%):
ID | Type | Name | Security | |
---|---|---|---|---|
1234 | Option | Tagg Palmer | ES-123 |
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:
<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>
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.
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.
data
-
adjust accordingly.prettier
, which will
also tell you if/where you have a syntax error.@@ Playroom:xx @@
) should be helpful to the starting line for
the code changes.@ngneat/falso
to generate fake data.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>
The most important tool coming from the virtualizer is the function virtualizer.getVirtualItems()
,
which returns an array of VirtualRow
s 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>)
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.
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:
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>
At this point, our twiddle rows are completely functional, but require minor design adjustments for better alignment:
40px
twiddle button column into account.@@ 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>
ID | Name | Security |
---|
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;}
The virtualizer
return value from useTableVirtualizer
extends the
Virtualizer object from @tanstack/react-virtual
, adding the
following ink-specific methods:
Method | Description |
---|---|
getWrapperProps | Call and destructure its returned object on <NewTable.VirtualWrapper> . |
getBodyProps | Call and destructure its returned object on <NewTable.Head> . |
getRowProps | Pass a virtualRow and destructure its returned object on each <NewTable.Row> . |
getRowItem | Pass 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
The following options come from the @tanstack/react-virtual API directly and are not required, but might be useful:
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.
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.
<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?