import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
//3 TanStack Libraries!!!
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
OnChangeFn,
Row,
SortingState,
useReactTable,
} from '@tanstack/react-table'
import {
keepPreviousData,
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { fetchData, Person, PersonApiResponse } from './makeData'
const fetchSize = 50
function App() {
//we need a reference to the scrolling element for logic down below
const tableContainerRef = React.useRef<HTMLDivElement>(null)
const [sorting, setSorting] = React.useState<SortingState>([])
const columns = React.useMemo<ColumnDef<Person>[]>(
() => [
{
accessorKey: 'id',
header: 'ID',
size: 60,
},
{
accessorKey: 'firstName',
cell: info => info.getValue(),
},
{
accessorFn: row => row.lastName,
id: 'lastName',
cell: info => info.getValue(),
header: () => <span>Last Name</span>,
},
{
accessorKey: 'age',
header: () => 'Age',
size: 50,
},
{
accessorKey: 'visits',
header: () => <span>Visits</span>,
size: 50,
},
{
accessorKey: 'status',
header: 'Status',
},
{
accessorKey: 'progress',
header: 'Profile Progress',
size: 80,
},
{
accessorKey: 'createdAt',
header: 'Created At',
cell: info => info.getValue<Date>().toLocaleString(),
size: 200,
},
],
[]
)
//react-query has a useInfiniteQuery hook that is perfect for this use case
const { data, fetchNextPage, isFetching, isLoading } =
useInfiniteQuery<PersonApiResponse>({
queryKey: [
'people',
sorting, //refetch when sorting changes
],
queryFn: async ({ pageParam = 0 }) => {
const start = (pageParam as number) * fetchSize
const fetchedData = await fetchData(start, fetchSize, sorting) //pretend api call
return fetchedData
},
initialPageParam: 0,
getNextPageParam: (_lastGroup, groups) => groups.length,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
})
//flatten the array of arrays from the useInfiniteQuery hook
const flatData = React.useMemo(
() => data?.pages?.flatMap(page => page.data) ?? [],
[data]
)
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0
const totalFetched = flatData.length
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
const fetchMoreOnBottomReached = React.useCallback(
(containerRefElement?: HTMLDivElement | null) => {
if (containerRefElement) {
const { scrollHeight, scrollTop, clientHeight } = containerRefElement
//once the user has scrolled within 500px of the bottom of the table, fetch more data if we can
if (
scrollHeight - scrollTop - clientHeight < 500 &&
!isFetching &&
totalFetched < totalDBRowCount
) {
fetchNextPage()
}
}
},
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
)
//a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data
React.useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current)
}, [fetchMoreOnBottomReached])
const table = useReactTable({
data: flatData,
columns,
state: {
sorting,
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualSorting: true,
debugTable: true,
})
//scroll to top of table when sorting changes
const handleSortingChange: OnChangeFn<SortingState> = updater => {
setSorting(updater)
if (!!table.getRowModel().rows.length) {
rowVirtualizer.scrollToIndex?.(0)
}
}
//since this table option is derived from table row model state, we're using the table.setOptions utility
table.setOptions(prev => ({
...prev,
onSortingChange: handleSortingChange,
}))
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
estimateSize: () => 33, //estimate row height for accurate scrollbar dragging
getScrollElement: () => tableContainerRef.current,
//measure dynamic row height, except in firefox because it measures table border height incorrectly
measureElement:
typeof window !== 'undefined' &&
navigator.userAgent.indexOf('Firefox') === -1
? element => element?.getBoundingClientRect().height
: undefined,
overscan: 5,
})
if (isLoading) {
return <>Loading...</>
}
return (
<div className="app">
{process.env.NODE_ENV === 'development' ? (
<p>
<strong>Notice:</strong> You are currently running React in
development mode. Virtualized rendering performance will be slightly
degraded until this application is built for production.
</p>
) : null}
({flatData.length} of {totalDBRowCount} rows fetched)
<div
className="container"
onScroll={e => fetchMoreOnBottomReached(e.currentTarget)}
ref={tableContainerRef}
style={{
overflow: 'auto', //our scrollable table container
position: 'relative', //needed for sticky header
height: '600px', //should be a fixed height
}}
>
{/* Even though we're still using sematic table tags, we must use CSS grid and flexbox for dynamic row heights */}
<table style={{ display: 'grid' }}>
<thead
style={{
display: 'grid',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
{table.getHeaderGroups().map(headerGroup => (
<tr
key={headerGroup.id}
style={{ display: 'flex', width: '100%' }}
>
{headerGroup.headers.map(header => {
return (
<th
key={header.id}
style={{
display: 'flex',
width: header.getSize(),
}}
>
<div
{...{
className: header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</div>
</th>
)
})}
</tr>
))}
</thead>
<tbody
style={{
display: 'grid',
height: `${rowVirtualizer.getTotalSize()}px`, //tells scrollbar how big the table is
position: 'relative', //needed for absolute positioning of rows
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = rows[virtualRow.index] as Row<Person>
return (
<tr
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={node => rowVirtualizer.measureElement(node)} //measure dynamic row height
key={row.id}
style={{
display: 'flex',
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
width: '100%',
}}
>
{row.getVisibleCells().map(cell => {
return (
<td
key={cell.id}
style={{
display: 'flex',
width: cell.column.getSize(),
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
{isFetching && <div>Fetching More...</div>}
</div>
)
}
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
const queryClient = new QueryClient()
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
//3 TanStack Libraries!!!
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
OnChangeFn,
Row,
SortingState,
useReactTable,
} from '@tanstack/react-table'
import {
keepPreviousData,
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { fetchData, Person, PersonApiResponse } from './makeData'
const fetchSize = 50
function App() {
//we need a reference to the scrolling element for logic down below
const tableContainerRef = React.useRef<HTMLDivElement>(null)
const [sorting, setSorting] = React.useState<SortingState>([])
const columns = React.useMemo<ColumnDef<Person>[]>(
() => [
{
accessorKey: 'id',
header: 'ID',
size: 60,
},
{
accessorKey: 'firstName',
cell: info => info.getValue(),
},
{
accessorFn: row => row.lastName,
id: 'lastName',
cell: info => info.getValue(),
header: () => <span>Last Name</span>,
},
{
accessorKey: 'age',
header: () => 'Age',
size: 50,
},
{
accessorKey: 'visits',
header: () => <span>Visits</span>,
size: 50,
},
{
accessorKey: 'status',
header: 'Status',
},
{
accessorKey: 'progress',
header: 'Profile Progress',
size: 80,
},
{
accessorKey: 'createdAt',
header: 'Created At',
cell: info => info.getValue<Date>().toLocaleString(),
size: 200,
},
],
[]
)
//react-query has a useInfiniteQuery hook that is perfect for this use case
const { data, fetchNextPage, isFetching, isLoading } =
useInfiniteQuery<PersonApiResponse>({
queryKey: [
'people',
sorting, //refetch when sorting changes
],
queryFn: async ({ pageParam = 0 }) => {
const start = (pageParam as number) * fetchSize
const fetchedData = await fetchData(start, fetchSize, sorting) //pretend api call
return fetchedData
},
initialPageParam: 0,
getNextPageParam: (_lastGroup, groups) => groups.length,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
})
//flatten the array of arrays from the useInfiniteQuery hook
const flatData = React.useMemo(
() => data?.pages?.flatMap(page => page.data) ?? [],
[data]
)
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0
const totalFetched = flatData.length
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
const fetchMoreOnBottomReached = React.useCallback(
(containerRefElement?: HTMLDivElement | null) => {
if (containerRefElement) {
const { scrollHeight, scrollTop, clientHeight } = containerRefElement
//once the user has scrolled within 500px of the bottom of the table, fetch more data if we can
if (
scrollHeight - scrollTop - clientHeight < 500 &&
!isFetching &&
totalFetched < totalDBRowCount
) {
fetchNextPage()
}
}
},
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
)
//a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data
React.useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current)
}, [fetchMoreOnBottomReached])
const table = useReactTable({
data: flatData,
columns,
state: {
sorting,
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualSorting: true,
debugTable: true,
})
//scroll to top of table when sorting changes
const handleSortingChange: OnChangeFn<SortingState> = updater => {
setSorting(updater)
if (!!table.getRowModel().rows.length) {
rowVirtualizer.scrollToIndex?.(0)
}
}
//since this table option is derived from table row model state, we're using the table.setOptions utility
table.setOptions(prev => ({
...prev,
onSortingChange: handleSortingChange,
}))
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
estimateSize: () => 33, //estimate row height for accurate scrollbar dragging
getScrollElement: () => tableContainerRef.current,
//measure dynamic row height, except in firefox because it measures table border height incorrectly
measureElement:
typeof window !== 'undefined' &&
navigator.userAgent.indexOf('Firefox') === -1
? element => element?.getBoundingClientRect().height
: undefined,
overscan: 5,
})
if (isLoading) {
return <>Loading...</>
}
return (
<div className="app">
{process.env.NODE_ENV === 'development' ? (
<p>
<strong>Notice:</strong> You are currently running React in
development mode. Virtualized rendering performance will be slightly
degraded until this application is built for production.
</p>
) : null}
({flatData.length} of {totalDBRowCount} rows fetched)
<div
className="container"
onScroll={e => fetchMoreOnBottomReached(e.currentTarget)}
ref={tableContainerRef}
style={{
overflow: 'auto', //our scrollable table container
position: 'relative', //needed for sticky header
height: '600px', //should be a fixed height
}}
>
{/* Even though we're still using sematic table tags, we must use CSS grid and flexbox for dynamic row heights */}
<table style={{ display: 'grid' }}>
<thead
style={{
display: 'grid',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
{table.getHeaderGroups().map(headerGroup => (
<tr
key={headerGroup.id}
style={{ display: 'flex', width: '100%' }}
>
{headerGroup.headers.map(header => {
return (
<th
key={header.id}
style={{
display: 'flex',
width: header.getSize(),
}}
>
<div
{...{
className: header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</div>
</th>
)
})}
</tr>
))}
</thead>
<tbody
style={{
display: 'grid',
height: `${rowVirtualizer.getTotalSize()}px`, //tells scrollbar how big the table is
position: 'relative', //needed for absolute positioning of rows
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = rows[virtualRow.index] as Row<Person>
return (
<tr
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={node => rowVirtualizer.measureElement(node)} //measure dynamic row height
key={row.id}
style={{
display: 'flex',
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
width: '100%',
}}
>
{row.getVisibleCells().map(cell => {
return (
<td
key={cell.id}
style={{
display: 'flex',
width: cell.column.getSize(),
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
{isFetching && <div>Fetching More...</div>}
</div>
)
}
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
const queryClient = new QueryClient()
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.