Skip to content

Commit

Permalink
Make DataTable less prone to unnecessary re-renders
Browse files Browse the repository at this point in the history
 - Memo context provider values in various components. Otherwise
   every render of the component containing the `*.Provider` will cause
   any descendant consumers to re-render, even if the descendant has
   been memoed.

   Before this change, JSX like the following would result in a
   `DataTable` being re-rendered on every `Container` render:

   ```
   function Container() {
     const table = useMemo(() => <DataTable ... />, [...]);
     return <Scroll>{table}</Scroll>;
   }
   ```

   This happened because the `Scroll` component contained a context
   provider that was consumed by the `DataTable` and thus every render
   of the `Scroll` would re-render the `DataTable` child.

 - Memo rendered `DataTable` rows such that they only re-render if one
   of the following changes:

    - The row content
    - The columns
    - A custom `renderItem` callback
    - The selected row

   Users of `DataTable` can then more easily eliminate re-renders of
   large tables by memoing these props/callbacks.
  • Loading branch information
robertknight committed Nov 9, 2023
1 parent 96c8318 commit b389fd5
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 52 deletions.
82 changes: 50 additions & 32 deletions src/components/data/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ComponentChildren, JSX } from 'preact';
import { useContext, useEffect } from 'preact/hooks';
import { useCallback, useContext, useEffect, useMemo } from 'preact/hooks';

import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation';
import { useStableCallback } from '../../hooks/use-stable-callback';
import { useSyncedRef } from '../../hooks/use-synced-ref';
import type { CompositeProps } from '../../types';
import { downcastRef } from '../../util/typing';
Expand Down Expand Up @@ -49,6 +50,10 @@ export type DataTableProps<Row> = CompositeProps &
ComponentProps<Row> &
Omit<JSX.HTMLAttributes<HTMLElement>, 'size' | 'rows' | 'role' | 'loading'>;

function defaultRenderItem<Row>(r: Row, field: keyof Row): ComponentChildren {
return r[field] as ComponentChildren;
}

/**
* An interactive table of rows and columns with a sticky header.
*/
Expand All @@ -61,7 +66,7 @@ export default function DataTable<Row>({
title,
selectedRow,
loading = false,
renderItem = (r: Row, field: keyof Row) => r[field] as ComponentChildren,
renderItem = defaultRenderItem,
onSelectRow,
onConfirmRow,
emptyMessage,
Expand All @@ -81,23 +86,25 @@ export default function DataTable<Row>({
});

const noContent = loading || (!rows.length && emptyMessage);
const fields = columns.map(column => column.field);
const fields = useMemo(() => columns.map(column => column.field), [columns]);

function selectRow(row: Row) {
const selectRow = useStableCallback((row: Row) => {
onSelectRow?.(row);
}

function confirmRow(row: Row) {
});
const confirmRow = useStableCallback((row: Row) => {
onConfirmRow?.(row);
}
});

function handleKeyDown(event: KeyboardEvent, row: Row) {
if (event.key === 'Enter') {
confirmRow(row);
event.preventDefault();
event.stopPropagation();
}
}
const handleKeyDown = useCallback(
(event: KeyboardEvent, row: Row) => {
if (event.key === 'Enter') {
confirmRow(row);
event.preventDefault();
event.stopPropagation();
}
},
[confirmRow],
);

// Ensure that a selected row is visible when this table is within
// a scrolling context
Expand Down Expand Up @@ -131,6 +138,33 @@ export default function DataTable<Row>({
// excess vertical space in tables with sparse rows data.
const withFoot = !loading && rows.length > 0;

const tableRows = useMemo(() => {
return rows.map((row, idx) => (
<TableRow
key={idx}
selected={row === selectedRow}
onClick={() => selectRow(row)}
onFocus={() => selectRow(row)}
onDblClick={() => confirmRow(row)}
onKeyDown={event => handleKeyDown(event, row)}
>
{fields.map(field => (
<TableCell key={field}>
{renderItem(row, field as keyof Row)}
</TableCell>
))}
</TableRow>
));
}, [
confirmRow,
fields,
renderItem,
handleKeyDown,
rows,
selectRow,
selectedRow,
]);

return (
<Table
data-composite-component="DataTable"
Expand All @@ -152,23 +186,7 @@ export default function DataTable<Row>({
</TableRow>
</TableHead>
<TableBody>
{!loading &&
rows.map((row, idx) => (
<TableRow
key={idx}
selected={row === selectedRow}
onClick={() => selectRow(row)}
onFocus={() => selectRow(row)}
onDblClick={() => confirmRow(row)}
onKeyDown={event => handleKeyDown(event, row)}
>
{fields.map(field => (
<TableCell key={field}>
{renderItem(row, field as keyof Row)}
</TableCell>
))}
</TableRow>
))}
{!loading && tableRows}
{noContent && (
<tr>
<td colSpan={columns.length} className="text-center p-3">
Expand Down
10 changes: 7 additions & 3 deletions src/components/data/Scroll.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classnames from 'classnames';
import type { JSX } from 'preact';
import { useMemo } from 'preact/hooks';

import { useSyncedRef } from '../../hooks/use-synced-ref';
import type { PresentationalProps } from '../../types';
Expand All @@ -26,9 +27,12 @@ export default function Scroll({
}: ScrollProps) {
const ref = useSyncedRef(elementRef);

const scrollContext: ScrollInfo = {
scrollRef: ref,
};
const scrollContext: ScrollInfo = useMemo(
() => ({
scrollRef: ref,
}),
[ref],
);

return (
<ScrollContext.Provider value={scrollContext}>
Expand Down
16 changes: 10 additions & 6 deletions src/components/data/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classnames from 'classnames';
import type { JSX } from 'preact';
import { useMemo } from 'preact/hooks';

import { useSyncedRef } from '../../hooks/use-synced-ref';
import type { PresentationalProps } from '../../types';
Expand Down Expand Up @@ -34,12 +35,15 @@ export default function Table({
}: TableProps) {
const ref = useSyncedRef(elementRef);

const tableContext: TableInfo = {
interactive,
stickyHeader,
borderless,
tableRef: ref,
};
const tableContext: TableInfo = useMemo(
() => ({
interactive,
stickyHeader,
borderless,
tableRef: ref,
}),
[borderless, interactive, stickyHeader, ref],
);

return (
<TableContext.Provider value={tableContext}>
Expand Down
11 changes: 7 additions & 4 deletions src/components/data/TableBody.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classnames from 'classnames';
import type { JSX } from 'preact';
import { useContext } from 'preact/hooks';
import { useContext, useMemo } from 'preact/hooks';

import type { PresentationalProps } from '../../types';
import { downcastRef } from '../../util/typing';
Expand All @@ -22,9 +22,12 @@ export default function TableBody({
...htmlAttributes
}: TableBodyProps) {
const tableContext = useContext(TableContext);
const sectionContext: TableSection = {
section: 'body',
};
const sectionContext: TableSection = useMemo(
() => ({
section: 'body',
}),
[],
);

return (
<TableSectionContext.Provider value={sectionContext}>
Expand Down
10 changes: 7 additions & 3 deletions src/components/data/TableFoot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classnames from 'classnames';
import type { JSX } from 'preact';
import { useMemo } from 'preact/hooks';

import type { PresentationalProps } from '../../types';
import { downcastRef } from '../../util/typing';
Expand All @@ -20,9 +21,12 @@ export default function TableFoot({

...htmlAttributes
}: TableFootProps) {
const sectionContext: TableSection = {
section: 'foot',
};
const sectionContext: TableSection = useMemo(
() => ({
section: 'foot',
}),
[],
);

return (
<TableSectionContext.Provider value={sectionContext}>
Expand Down
11 changes: 7 additions & 4 deletions src/components/data/TableHead.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classnames from 'classnames';
import type { JSX } from 'preact';
import { useContext } from 'preact/hooks';
import { useContext, useMemo } from 'preact/hooks';

import type { PresentationalProps } from '../../types';
import { downcastRef } from '../../util/typing';
Expand All @@ -23,9 +23,12 @@ export default function TableHead({
}: TableHeadProps) {
const tableContext = useContext(TableContext);

const sectionContext: TableSection = {
section: 'head',
};
const sectionContext: TableSection = useMemo(
() => ({
section: 'head',
}),
[],
);

return (
<TableSectionContext.Provider value={sectionContext}>
Expand Down

0 comments on commit b389fd5

Please sign in to comment.