I am building the table with react-window
and react-window-infinite-loader
to these requirements.
Requirements
- Vertical direction
- Infinite scroll
- List virtualization
- Fixed height of table
- The sticky table header
With referring Github issues of react-window
, I was able to implement the table like below.
// Page.tsx
import { LinksFunction } from "@remix-run/node";
import {
CSSProperties,
Fragment,
HTMLProps,
createContext,
forwardRef,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { FixedSizeList } from "react-window";
import InfiniteLoader from "react-window-infinite-loader";
import style from "../styles/table.css?url";
export const links: LinksFunction = () => [
{
rel: "stylesheet",
href: style,
},
];
type TableContext = {
top?: number;
setTop: (top: number) => unknown;
};
type Item = {
id: number;
title: string;
content: string;
createdAt: string;
age: number;
hex: string;
};
const Context = createContext<TableContext>({
top: null!,
setTop: null!,
});
function TableHead() {
return (
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Title</th>
<th scope="col">Content</th>
<th scope="col">Created At</th>
<th scope="col">Age</th>
<th scope="col">Hex Code</th>
</tr>
</thead>
);
}
export interface TableRowProps {
item: Item;
style: CSSProperties;
}
function TableRow(props: TableRowProps) {
const { item, style } = props;
return (
<tr style={{ ...style }}>
<td height={60}>{item.id}</td>
<td>{item.title.toUpperCase()}</td>
<td>{item.content}</td>
<td>{item.createdAt}</td>
<td>{item.age}</td>
<td>{item.hex}</td>
</tr>
);
}
const TableInnerElement = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
function TableInnerElement({ children, ...props }, ref) {
const { top } = useContext(Context);
return (
<div {...props} ref={ref}>
<table style={{ position: "absolute", top, width: "100%" }}>
<TableHead />
<tbody>{children}</tbody>
</table>
</div>
);
}
);
// Emulate fetching Apis.
function createItems(size: number) {
const newItems = Array.from({ length: 20 }).map((_, index) => {
const id = size + index;
const title = Math.random()
.toString(16)
.replace(/(d|.)/g, "");
const content = Math.random()
.toString(36)
.replace(/(d|.)/g, "");
const createdAt = new Date().toLocaleString();
const age = Math.floor(Math.random() * 60) + 20;
const hexCode = Math.random().toString(16).replace(/./g, "").slice(0, 6);
return {
id,
title,
content,
createdAt,
age,
hex: hexCode,
} satisfies Item;
});
return newItems;
}
// Emulate network delay.
function wait(interval: number = 1000) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(null!);
}, interval);
});
}
export default function Page() {
const [top, setTop] = useState(0);
const [state, setState] = useState({
items: [] as Item[],
isLoading: false,
});
const itemCount = state.items ? state.items.length + 1 : 0;
const listRef = useRef(null as FixedSizeList | null);
async function fetchNextItems() {
setState((currentState) => ({
...currentState,
isLoading: true,
}));
await wait(3000);
setState((currentState) => {
const maxLength = currentState.items.length;
return {
items: currentState.items.concat(createItems(maxLength)),
isLoading: false,
};
});
}
useEffect(() => {
setState(() => ({
isLoading: true,
items: createItems(0),
}));
setState((state) => ({
...state,
isLoading: false,
}));
}, []);
return (
<div className="page">
<Context.Provider
value={{
top,
setTop,
}}
>
<InfiniteLoader
isItemLoaded={(index) => !!(state && state.items.at(index))}
itemCount={itemCount}
loadMoreItems={() => {
if (!state.isLoading) {
fetchNextItems();
}
}}
>
{({ onItemsRendered, ref: loaderRef }) => {
return (
<FixedSizeList
height={600}
itemCount={itemCount}
itemSize={60}
overscanCount={4}
width={"100%"}
className="pageTable"
ref={(ref) => {
loaderRef(ref);
listRef.current = ref;
}}
useIsScrolling
onItemsRendered={(props) => {
// @ts-expect-error Style for rendering items on table.
const style = listRef.current?._getItemStyle(
// eslint-disable-next-line react/prop-types
props.overscanStartIndex
);
if (style && "top" in style) {
const nextTop = style?.top ?? 0;
setTop(nextTop as number);
}
return onItemsRendered(props);
}}
innerElementType={TableInnerElement}
>
{({ index }) => {
const item = state.items?.at(index);
if (!item) {
return <Fragment key={"fragment"} />;
}
return <TableRow item={item} style={{}} />;
}}
</FixedSizeList>
);
}}
</InfiniteLoader>
</Context.Provider>
</div>
);
}
/* table.css */
.page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.pageTable {
scroll-behavior: smooth;
margin-top: 40px;
}
.pageTable thead {
position: sticky;
top: 0;
left: 0;
right: 0;
height: 60px;
background-color: white;
}
.pageTable tr > td, .pageTable tr > th {
white-space: nowrap;
}
.pageTable tr > td:nth-child(1), .pageTable tr > th:nth-child(1) {
min-width: 72px;
width: 72px;
}
.pageTable tr > td:nth-child(2), .pageTable tr > th:nth-child(2) {
width: 250px;
min-width: 250px;
}
.pageTable tr > td:nth-child(3), .pageTable tr > th:nth-child(3) {
min-width: 400px;
width: 400px;
}
.pageTable tr > td:nth-child(4), .pageTable tr > th:nth-child(4) {
width: 180px;
min-width: 180px;
}
.pageTable tr > td:nth-child(5), .pageTable tr > th:nth-child(5) {
width: 100px;
min-width: 100px;
}
.pageTable tr > td:nth-child(6), .pageTable tr > th:nth-child(6) {
width: 230px;
min-width: 230px;
}
Everything works fine. But there is one problem yet.
When scrolling upward, table header often detached from the top part of the table.
Here is the captured video.
Example
If I switched sticky to absolute in thead styling, then layouts of th elements in thead, and td elements in tbody do not matches.
Are there any ways to prevent sticky header jumping from table when user are scrolling?