MRT logoMaterial React Table

Editing (CRUD) Tree Example

Editing nested row data can be challenging. In this example, rows can be expanded to show sub rows, and editing and creating new rows can be done at any level.

Adding a new blank row in the exact row index position is now possible with the new positionCreatingRow table option.

This example also shows some complex client-side optimistic updates, where the table data is updated immediately after a valid user action.

Non TanStack Query Fetching
More Examples

Demo

Open StackblitzOpen Code SandboxOpen on GitHub
1-20 of 20

Source Code

1import { lazy, Suspense, useMemo, useState } from 'react';
2import {
3 MaterialReactTable,
4 createRow,
5 type MRT_ColumnDef,
6 type MRT_Row,
7 type MRT_TableOptions,
8 useMaterialReactTable,
9} from 'material-react-table';
10import {
11 Box,
12 Button,
13 IconButton,
14 Tooltip,
15 darken,
16 lighten,
17} from '@mui/material';
18import {
19 QueryClient,
20 QueryClientProvider,
21 useMutation,
22 useQuery,
23 useQueryClient,
24} from '@tanstack/react-query';
25import { type User, fakeData, usStates } from './makeData';
26import PersonAddAltIcon from '@mui/icons-material/PersonAddAlt';
27import EditIcon from '@mui/icons-material/Edit';
28import DeleteIcon from '@mui/icons-material/Delete';
29
30const Example = () => {
31 const [creatingRowIndex, setCreatingRowIndex] = useState<
32 number | undefined
33 >();
34 const [validationErrors, setValidationErrors] = useState<
35 Record<string, string | undefined>
36 >({});
37
38 const columns = useMemo<MRT_ColumnDef<User>[]>(
39 () => [
40 {
41 accessorKey: 'id',
42 header: 'Id',
43 enableEditing: false,
44 size: 80,
45 },
46 {
47 accessorKey: 'firstName',
48 header: 'First Name',
49 muiEditTextFieldProps: {
50 required: true,
51 error: !!validationErrors?.firstName,
52 helperText: validationErrors?.firstName,
53 //remove any previous validation errors when user focuses on the input
54 onFocus: () =>
55 setValidationErrors({
56 ...validationErrors,
57 firstName: undefined,
58 }),
59 //optionally add validation checking for onBlur or onChange
60 },
61 },
62 {
63 accessorKey: 'lastName',
64 header: 'Last Name',
65 muiEditTextFieldProps: {
66 required: true,
67 error: !!validationErrors?.lastName,
68 helperText: validationErrors?.lastName,
69 //remove any previous validation errors when user focuses on the input
70 onFocus: () =>
71 setValidationErrors({
72 ...validationErrors,
73 lastName: undefined,
74 }),
75 },
76 },
77 {
78 accessorKey: 'city',
79 header: 'City',
80 muiEditTextFieldProps: {
81 required: true,
82 error: !!validationErrors?.city,
83 helperText: validationErrors?.city,
84 //remove any previous validation errors when user focuses on the input
85 onFocus: () =>
86 setValidationErrors({
87 ...validationErrors,
88 city: undefined,
89 }),
90 },
91 },
92 {
93 accessorKey: 'state',
94 header: 'State',
95 editVariant: 'select',
96 editSelectOptions: usStates,
97 muiEditTextFieldProps: {
98 select: true,
99 error: !!validationErrors?.state,
100 helperText: validationErrors?.state,
101 },
102 },
103 ],
104 [validationErrors],
105 );
106
107 //call CREATE hook
108 const { mutateAsync: createUser, isPending: isCreatingUser } =
109 useCreateUser();
110 //call READ hook
111 const {
112 data: fetchedUsers = [],
113 isError: isLoadingUsersError,
114 isFetching: isFetchingUsers,
115 isLoading: isLoadingUsers,
116 } = useGetUsers();
117 //call UPDATE hook
118 const { mutateAsync: updateUser, isPending: isUpdatingUser } =
119 useUpdateUser();
120 //call DELETE hook
121 const { mutateAsync: deleteUser, isPending: isDeletingUser } =
122 useDeleteUser();
123
124 //CREATE action
125 const handleCreateUser: MRT_TableOptions<User>['onCreatingRowSave'] = async ({
126 values,
127 row,
128 table,
129 }) => {
130 const newValidationErrors = validateUser(values);
131 if (Object.values(newValidationErrors).some((error) => error)) {
132 setValidationErrors(newValidationErrors);
133 return;
134 }
135 setValidationErrors({});
136 await createUser({ ...values, managerId: row.original.managerId });
137 table.setCreatingRow(null); //exit creating mode
138 };
139
140 //UPDATE action
141 const handleSaveUser: MRT_TableOptions<User>['onEditingRowSave'] = async ({
142 values,
143 table,
144 }) => {
145 const newValidationErrors = validateUser(values);
146 if (Object.values(newValidationErrors).some((error) => error)) {
147 setValidationErrors(newValidationErrors);
148 return;
149 }
150 setValidationErrors({});
151 await updateUser(values);
152 table.setEditingRow(null); //exit editing mode
153 };
154
155 //DELETE action
156 const openDeleteConfirmModal = (row: MRT_Row<User>) => {
157 if (window.confirm('Are you sure you want to delete this user?')) {
158 deleteUser(row.original.id);
159 }
160 };
161
162 const table = useMaterialReactTable({
163 columns,
164 data: fetchedUsers,
165 createDisplayMode: 'row', // ('modal', and 'custom' are also available)
166 editDisplayMode: 'row', // ('modal', 'cell', 'table', and 'custom' are also available)
167 enableColumnPinning: true,
168 enableEditing: true,
169 enableExpanding: true,
170 positionCreatingRow: creatingRowIndex, //index where new row is inserted before
171 getRowId: (row) => row.id,
172 muiToolbarAlertBannerProps: isLoadingUsersError
173 ? {
174 color: 'error',
175 children: 'Error loading data',
176 }
177 : undefined,
178 muiTableContainerProps: {
179 sx: {
180 minHeight: '500px',
181 },
182 },
183 muiTableBodyRowProps: ({ row }) => ({
184 //conditional styling based on row depth
185 sx: (theme) => ({
186 td: {
187 backgroundColor: darken(
188 lighten(theme.palette.background.paper, 0.1),
189 row.depth * (theme.palette.mode === 'dark' ? 0.2 : 0.1),
190 ),
191 },
192 }),
193 }),
194 onCreatingRowCancel: () => setValidationErrors({}),
195 onCreatingRowSave: handleCreateUser,
196 onEditingRowCancel: () => setValidationErrors({}),
197 onEditingRowSave: handleSaveUser,
198 renderRowActions: ({ row, staticRowIndex, table }) => (
199 <Box sx={{ display: 'flex', gap: '1rem' }}>
200 <Tooltip title="Edit">
201 <IconButton onClick={() => table.setEditingRow(row)}>
202 <EditIcon />
203 </IconButton>
204 </Tooltip>
205 <Tooltip title="Delete">
206 <IconButton color="error" onClick={() => openDeleteConfirmModal(row)}>
207 <DeleteIcon />
208 </IconButton>
209 </Tooltip>
210 <Tooltip title="Add Subordinate">
211 <IconButton
212 onClick={() => {
213 setCreatingRowIndex((staticRowIndex || 0) + 1);
214 table.setCreatingRow(
215 createRow(
216 table,
217 {
218 id: null!,
219 firstName: '',
220 lastName: '',
221 city: '',
222 state: '',
223 managerId: row.id,
224 subRows: [],
225 },
226 -1,
227 row.depth + 1,
228 ),
229 );
230 }}
231 >
232 <PersonAddAltIcon />
233 </IconButton>
234 </Tooltip>
235 </Box>
236 ),
237 renderTopToolbarCustomActions: ({ table }) => (
238 <Button
239 startIcon={<PersonAddAltIcon />}
240 variant="contained"
241 onClick={() => {
242 setCreatingRowIndex(table.getRowModel().rows.length); //create new row at bottom of table
243 table.setCreatingRow(true);
244 }}
245 >
246 Create New User
247 </Button>
248 ),
249 initialState: {
250 columnPinning: { left: ['mrt-row-actions'], right: [] },
251 expanded: true,
252 pagination: { pageSize: 20, pageIndex: 0 },
253 },
254 state: {
255 isLoading: isLoadingUsers,
256 isSaving: isCreatingUser || isUpdatingUser || isDeletingUser,
257 showAlertBanner: isLoadingUsersError,
258 showProgressBars: isFetchingUsers,
259 },
260 });
261
262 return <MaterialReactTable table={table} />;
263};
264
265//CREATE hook (post new user to api)
266function useCreateUser() {
267 const queryClient = useQueryClient();
268 return useMutation({
269 mutationFn: async (user: User) => {
270 console.info('create user', user);
271 //send api update request here
272 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call
273 return Promise.resolve();
274 },
275 //client side optimistic update
276 onMutate: (newUserInfo: User) => {
277 queryClient.setQueryData(['users'], (_prevUsers: User[]) => {
278 const prevUsers: User[] = JSON.parse(JSON.stringify(_prevUsers));
279 newUserInfo.subRows = [];
280 if (newUserInfo.managerId) {
281 const manager = findUserInTree(newUserInfo.managerId, prevUsers);
282 if (manager) {
283 manager.subRows = [
284 ...(manager.subRows || []),
285 {
286 ...newUserInfo,
287 id: `${manager.id}.${(manager.subRows?.length || 0) + 1}`,
288 },
289 ];
290 }
291 } else {
292 prevUsers.push({
293 ...newUserInfo,
294 id: `${prevUsers.length + 1}`,
295 });
296 }
297 return [...prevUsers];
298 });
299 },
300 // onSettled: () => queryClient.invalidateQueries({ queryKey: ['users'] }), //refetch users after mutation, disabled for demo
301 });
302}
303
304//READ hook (get users from api)
305function useGetUsers() {
306 return useQuery<User[]>({
307 queryKey: ['users'],
308 queryFn: async () => {
309 //send api request here
310 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call
311 return Promise.resolve(fakeData);
312 },
313 refetchOnWindowFocus: false,
314 });
315}
316
317//UPDATE hook (put user in api)
318function useUpdateUser() {
319 const queryClient = useQueryClient();
320 return useMutation({
321 mutationFn: async (user: User) => {
322 console.info('update user', user);
323 //send api update request here
324 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call
325 return Promise.resolve();
326 },
327 //client side optimistic update
328 onMutate: (newUserInfo: User) => {
329 queryClient.setQueryData(['users'], (prevUsers: any) => {
330 let user = findUserInTree(newUserInfo.id, prevUsers);
331 user = { ...user, ...newUserInfo };
332 return [...prevUsers];
333 });
334 },
335 // onSettled: () => queryClient.invalidateQueries({ queryKey: ['users'] }), //refetch users after mutation, disabled for demo
336 });
337}
338
339//DELETE hook (delete user in api)
340function useDeleteUser() {
341 const queryClient = useQueryClient();
342 return useMutation({
343 mutationFn: async (userId: string) => {
344 console.info('delete user', userId);
345 //send api update request here
346 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call
347 return Promise.resolve();
348 },
349 //client side optimistic update
350 onMutate: (userId: string) => {
351 queryClient.setQueryData(['users'], (prevUsers: any) => {
352 const newUsers: User[] = JSON.parse(JSON.stringify(prevUsers));
353 //remove user
354 const user = findUserInTree(userId, newUsers);
355 if (user) {
356 const manager = findUserInTree(user.managerId, newUsers);
357 if (manager) {
358 manager.subRows = manager.subRows?.filter(
359 (subUser) => subUser.id !== user.id,
360 );
361 } else {
362 return newUsers.filter((user) => user.id !== userId);
363 }
364 }
365 return [...newUsers];
366 });
367 },
368 // onSettled: () => queryClient.invalidateQueries({ queryKey: ['users'] }), //refetch users after mutation, disabled for demo
369 });
370}
371
372//react query setup in App.tsx
373const ReactQueryDevtoolsProduction = lazy(() =>
374 import('@tanstack/react-query-devtools/build/modern/production.js').then(
375 (d) => ({
376 default: d.ReactQueryDevtools,
377 }),
378 ),
379);
380
381const queryClient = new QueryClient();
382
383export default function App() {
384 return (
385 <QueryClientProvider client={queryClient}>
386 <Example />
387 <Suspense fallback={null}>
388 <ReactQueryDevtoolsProduction />
389 </Suspense>
390 </QueryClientProvider>
391 );
392}
393
394const validateRequired = (value: string) => !!value.length;
395
396function validateUser(user: User) {
397 return {
398 firstName: !validateRequired(user.firstName)
399 ? 'First Name is Required'
400 : '',
401 lastName: !validateRequired(user.lastName) ? 'Last Name is Required' : '',
402 };
403}
404
405function findUserInTree(managerId: string | null, users: User[]): User | null {
406 for (let i = 0; i < users.length; i++) {
407 if (users[i].id === managerId) {
408 return users[i];
409 }
410 if (users[i].subRows) {
411 const found = findUserInTree(managerId, users[i].subRows!);
412 if (found) return found;
413 }
414 }
415 return null;
416}
417

View Extra Storybook Examples