MRT logoMantine React Table

Infinite Scrolling Example

An infinite scrolling table is a table that streams data from a remote server as the user scrolls down the table. This works great with large datasets, just like our Virtualized Example, except here we do not fetch all of the data at once upfront. Instead, we just fetch data a little bit at a time, as it becomes necessary.
Using a library like @tanstack/react-query makes it easy to implement an infinite scrolling table in Mantine React Table with the useInfiniteQuery hook.
Enabling the virtualization feature is actually optional here but is encouraged if the table will be expected to render more than 100 rows at a time.
Fetched 0 of 0 total rows.
1
import {
2
type UIEvent,
3
useCallback,
4
useEffect,
5
useMemo,
6
useRef,
7
useState,
8
} from 'react';
9
import {
10
MantineReactTable,
11
useMantineReactTable,
12
type MRT_ColumnDef,
13
type MRT_ColumnFiltersState,
14
type MRT_SortingState,
15
type MRT_Virtualizer,
16
} from 'mantine-react-table';
17
import { Text } from '@mantine/core';
18
import {
19
QueryClient,
20
QueryClientProvider,
21
useInfiniteQuery,
22
} from '@tanstack/react-query';
23
24
type UserApiResponse = {
25
data: Array<User>;
26
meta: {
27
totalRowCount: number;
28
};
29
};
30
31
type User = {
32
firstName: string;
33
lastName: string;
34
address: string;
35
state: string;
36
phoneNumber: string;
37
};
38
39
const columns: MRT_ColumnDef<User>[] = [
40
{
41
accessorKey: 'firstName',
42
header: 'First Name',
43
},
44
{
45
accessorKey: 'lastName',
46
header: 'Last Name',
47
},
48
{
49
accessorKey: 'address',
50
header: 'Address',
51
},
52
{
53
accessorKey: 'state',
54
header: 'State',
55
},
56
{
57
accessorKey: 'phoneNumber',
58
header: 'Phone Number',
59
},
60
];
61
62
const fetchSize = 25;
63
64
const Example = () => {
65
const tableContainerRef = useRef<HTMLDivElement>(null); //we can get access to the underlying TableContainer element and react to its scroll events
66
const rowVirtualizerInstanceRef =
67
useRef<MRT_Virtualizer<HTMLDivElement, HTMLTableRowElement>>(null); //we can get access to the underlying Virtualizer instance and call its scrollToIndex method
68
69
const [columnFilters, setColumnFilters] = useState<MRT_ColumnFiltersState>(
70
[],
71
);
72
const [globalFilter, setGlobalFilter] = useState<string>();
73
const [sorting, setSorting] = useState<MRT_SortingState>([]);
74
75
const { data, fetchNextPage, isError, isFetching, isLoading } =
76
useInfiniteQuery<UserApiResponse>({
77
queryKey: ['table-data', columnFilters, globalFilter, sorting],
78
queryFn: async ({ pageParam = 0 }) => {
79
const url = new URL(
80
'/api/data',
81
process.env.NODE_ENV === 'production'
82
? 'https://www.mantine-react-table.com'
83
: 'http://localhost:3001',
84
);
85
url.searchParams.set('start', `${pageParam * fetchSize}`);
86
url.searchParams.set('size', `${fetchSize}`);
87
url.searchParams.set('filters', JSON.stringify(columnFilters ?? []));
88
url.searchParams.set('globalFilter', globalFilter ?? '');
89
url.searchParams.set('sorting', JSON.stringify(sorting ?? []));
90
91
const response = await fetch(url.href);
92
const json = (await response.json()) as UserApiResponse;
93
return json;
94
},
95
getNextPageParam: (_lastGroup, groups) => groups.length,
96
keepPreviousData: true,
97
refetchOnWindowFocus: false,
98
});
99
100
const flatData = useMemo(
101
() => data?.pages.flatMap((page) => page.data) ?? [],
102
[data],
103
);
104
105
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
106
const totalFetched = flatData.length;
107
108
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
109
const fetchMoreOnBottomReached = useCallback(
110
(containerRefElement?: HTMLDivElement | null) => {
111
if (containerRefElement) {
112
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
113
//once the user has scrolled within 400px of the bottom of the table, fetch more data if we can
114
if (
115
scrollHeight - scrollTop - clientHeight < 400 &&
116
!isFetching &&
117
totalFetched < totalDBRowCount
118
) {
119
fetchNextPage();
120
}
121
}
122
},
123
[fetchNextPage, isFetching, totalFetched, totalDBRowCount],
124
);
125
126
//scroll to top of table when sorting or filters change
127
useEffect(() => {
128
if (rowVirtualizerInstanceRef.current) {
129
try {
130
rowVirtualizerInstanceRef.current.scrollToIndex(0);
131
} catch (e) {
132
console.error(e);
133
}
134
}
135
}, [sorting, columnFilters, globalFilter]);
136
137
//a check on mount to see if the table is already scrolled to the bottom and immediately needs to fetch more data
138
useEffect(() => {
139
fetchMoreOnBottomReached(tableContainerRef.current);
140
}, [fetchMoreOnBottomReached]);
141
142
const table = useMantineReactTable({
143
columns,
144
data: flatData,
145
enablePagination: false,
146
enableRowNumbers: true,
147
enableRowVirtualization: true, //optional, but recommended if it is likely going to be more than 100 rows
148
manualFiltering: true,
149
manualSorting: true,
150
mantineTableContainerProps: {
151
ref: tableContainerRef, //get access to the table container element
152
sx: { maxHeight: '600px' }, //give the table a max height
153
onScroll: (
154
event: UIEvent<HTMLDivElement>, //add an event listener to the table container element
155
) => fetchMoreOnBottomReached(event.target as HTMLDivElement),
156
},
157
mantineToolbarAlertBannerProps: {
158
color: 'red',
159
children: 'Error loading data',
160
},
161
onColumnFiltersChange: setColumnFilters,
162
onGlobalFilterChange: setGlobalFilter,
163
onSortingChange: setSorting,
164
renderBottomToolbarCustomActions: () => (
165
<Text>
166
Fetched {totalFetched} of {totalDBRowCount} total rows.
167
</Text>
168
),
169
state: {
170
columnFilters,
171
globalFilter,
172
isLoading,
173
showAlertBanner: isError,
174
showProgressBars: isFetching,
175
sorting,
176
},
177
rowVirtualizerInstanceRef, //get access to the virtualizer instance
178
rowVirtualizerProps: { overscan: 10 },
179
});
180
181
return <MantineReactTable table={table} />;
182
};
183
184
const queryClient = new QueryClient();
185
186
const ExampleWithReactQueryProvider = () => (
187
<QueryClientProvider client={queryClient}>
188
<Example />
189
</QueryClientProvider>
190
);
191
192
export default ExampleWithReactQueryProvider;
1
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
3
import { Text } from '@mantine/core';
4
import {
5
QueryClient,
6
QueryClientProvider,
7
useInfiniteQuery,
8
} from '@tanstack/react-query';
9
10
const columns = [
11
{
12
accessorKey: 'firstName',
13
header: 'First Name',
14
},
15
{
16
accessorKey: 'lastName',
17
header: 'Last Name',
18
},
19
{
20
accessorKey: 'address',
21
header: 'Address',
22
},
23
{
24
accessorKey: 'state',
25
header: 'State',
26
},
27
{
28
accessorKey: 'phoneNumber',
29
header: 'Phone Number',
30
},
31
];
32
33
const fetchSize = 25;
34
35
const Example = () => {
36
const tableContainerRef = useRef(null); //we can get access to the underlying TableContainer element and react to its scroll events
37
const rowVirtualizerInstanceRef = useRef(null); //we can get access to the underlying Virtualizer instance and call its scrollToIndex method
38
39
const [columnFilters, setColumnFilters] = useState([]);
40
const [globalFilter, setGlobalFilter] = useState();
41
const [sorting, setSorting] = useState([]);
42
43
const { data, fetchNextPage, isError, isFetching, isLoading } =
44
useInfiniteQuery({
45
queryKey: ['table-data', columnFilters, globalFilter, sorting],
46
queryFn: async ({ pageParam = 0 }) => {
47
const url = new URL(
48
'/api/data',
49
process.env.NODE_ENV === 'production'
50
? 'https://www.mantine-react-table.com'
51
: 'http://localhost:3001',
52
);
53
url.searchParams.set('start', `${pageParam * fetchSize}`);
54
url.searchParams.set('size', `${fetchSize}`);
55
url.searchParams.set('filters', JSON.stringify(columnFilters ?? []));
56
url.searchParams.set('globalFilter', globalFilter ?? '');
57
url.searchParams.set('sorting', JSON.stringify(sorting ?? []));
58
59
const response = await fetch(url.href);
60
const json = await response.json();
61
return json;
62
},
63
getNextPageParam: (_lastGroup, groups) => groups.length,
64
keepPreviousData: true,
65
refetchOnWindowFocus: false,
66
});
67
68
const flatData = useMemo(
69
() => data?.pages.flatMap((page) => page.data) ?? [],
70
[data],
71
);
72
73
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
74
const totalFetched = flatData.length;
75
76
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
77
const fetchMoreOnBottomReached = useCallback(
78
(containerRefElement) => {
79
if (containerRefElement) {
80
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
81
//once the user has scrolled within 400px of the bottom of the table, fetch more data if we can
82
if (
83
scrollHeight - scrollTop - clientHeight < 400 &&
84
!isFetching &&
85
totalFetched < totalDBRowCount
86
) {
87
fetchNextPage();
88
}
89
}
90
},
91
[fetchNextPage, isFetching, totalFetched, totalDBRowCount],
92
);
93
94
//scroll to top of table when sorting or filters change
95
useEffect(() => {
96
if (rowVirtualizerInstanceRef.current) {
97
try {
98
rowVirtualizerInstanceRef.current.scrollToIndex(0);
99
} catch (e) {
100
console.error(e);
101
}
102
}
103
}, [sorting, columnFilters, globalFilter]);
104
105
//a check on mount to see if the table is already scrolled to the bottom and immediately needs to fetch more data
106
useEffect(() => {
107
fetchMoreOnBottomReached(tableContainerRef.current);
108
}, [fetchMoreOnBottomReached]);
109
110
const table = useMantineReactTable({
111
columns,
112
data: flatData,
113
enablePagination: false,
114
enableRowNumbers: true,
115
enableRowVirtualization: true, //optional, but recommended if it is likely going to be more than 100 rows
116
manualFiltering: true,
117
manualSorting: true,
118
mantineTableContainerProps: {
119
ref: tableContainerRef, //get access to the table container element
120
sx: { maxHeight: '600px' }, //give the table a max height
121
onScroll: (
122
event, //add an event listener to the table container element
123
) => fetchMoreOnBottomReached(event.target),
124
},
125
mantineToolbarAlertBannerProps: {
126
color: 'red',
127
children: 'Error loading data',
128
},
129
onColumnFiltersChange: setColumnFilters,
130
onGlobalFilterChange: setGlobalFilter,
131
onSortingChange: setSorting,
132
renderBottomToolbarCustomActions: () => (
133
<Text>
134
Fetched {totalFetched} of {totalDBRowCount} total rows.
135
</Text>
136
),
137
state: {
138
columnFilters,
139
globalFilter,
140
isLoading,
141
showAlertBanner: isError,
142
showProgressBars: isFetching,
143
sorting,
144
},
145
rowVirtualizerInstanceRef, //get access to the virtualizer instance
146
rowVirtualizerProps: { overscan: 10 },
147
});
148
149
return <MantineReactTable table={table} />;
150
};
151
152
const queryClient = new QueryClient();
153
154
const ExampleWithReactQueryProvider = () => (
155
<QueryClientProvider client={queryClient}>
156
<Example />
157
</QueryClientProvider>
158
);
159
160
export default ExampleWithReactQueryProvider;
1
import {
2
type MRT_ColumnFiltersState,
3
type MRT_SortingState,
4
} from 'mantine-react-table';
5
import { type NextApiRequest, type NextApiResponse } from 'next';
6
import { getData } from './mock';
7
8
//This is just a simple mock of a backend API where you would do server-side pagination, filtering, and sorting
9
//You would most likely want way more validation and error handling than this in a real world application
10
//Also most of this logic should actually be in the database query itself, but this is just a mock
11
export default function handler(req: NextApiRequest, res: NextApiResponse) {
12
let dbData = getData();
13
const { start, size, filters, filterModes, sorting, globalFilter } =
14
req.query as Record<string, string>;
15
16
const parsedFilterModes = JSON.parse(filterModes ?? '{}') as Record<
17
string,
18
string
19
>;
20
21
const parsedColumnFilters = JSON.parse(filters) as MRT_ColumnFiltersState;
22
if (parsedColumnFilters?.length) {
23
parsedColumnFilters.map((filter) => {
24
const { id: columnId, value: filterValue } = filter;
25
const filterMode = parsedFilterModes?.[columnId] ?? 'contains';
26
dbData = dbData.filter((row) => {
27
const rowValue = row[columnId]?.toString()?.toLowerCase();
28
if (filterMode === 'contains') {
29
return rowValue.includes?.((filterValue as string).toLowerCase());
30
} else if (filterMode === 'startsWith') {
31
return rowValue.startsWith?.((filterValue as string).toLowerCase());
32
} else if (filterMode === 'endsWith') {
33
return rowValue.endsWith?.((filterValue as string).toLowerCase());
34
}
35
});
36
});
37
}
38
39
if (globalFilter) {
40
dbData = dbData.filter((row) =>
41
Object.keys(row).some(
42
(columnId) =>
43
row[columnId]
44
?.toString()
45
?.toLowerCase()
46
?.includes?.((globalFilter as string).toLowerCase()),
47
),
48
);
49
}
50
51
const parsedSorting = JSON.parse(sorting) as MRT_SortingState;
52
if (parsedSorting?.length) {
53
const sort = parsedSorting[0];
54
const { id, desc } = sort;
55
dbData.sort((a, b) => {
56
if (desc) {
57
return a[id] < b[id] ? 1 : -1;
58
}
59
return a[id] > b[id] ? 1 : -1;
60
});
61
}
62
63
res.status(200).json({
64
data:
65
dbData?.slice(parseInt(start), parseInt(start) + parseInt(size)) ?? [],
66
meta: { totalRowCount: dbData.length },
67
});
68
}
View Extra Storybook Examples
You can help make these docs better! PRs are Welcome
Using Material-UI instead of Mantine?
Check out Material React Table