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.
Actions | Id | First Name | Last Name | City | State | |
---|---|---|---|---|---|---|
20
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';2930const Example = () => {31 const [creatingRowIndex, setCreatingRowIndex] = useState<32 number | undefined33 >();34 const [validationErrors, setValidationErrors] = useState<35 Record<string, string | undefined>36 >({});3738 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 input54 onFocus: () =>55 setValidationErrors({56 ...validationErrors,57 firstName: undefined,58 }),59 //optionally add validation checking for onBlur or onChange60 },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 input70 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 input85 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 );106107 //call CREATE hook108 const { mutateAsync: createUser, isPending: isCreatingUser } =109 useCreateUser();110 //call READ hook111 const {112 data: fetchedUsers = [],113 isError: isLoadingUsersError,114 isFetching: isFetchingUsers,115 isLoading: isLoadingUsers,116 } = useGetUsers();117 //call UPDATE hook118 const { mutateAsync: updateUser, isPending: isUpdatingUser } =119 useUpdateUser();120 //call DELETE hook121 const { mutateAsync: deleteUser, isPending: isDeletingUser } =122 useDeleteUser();123124 //CREATE action125 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 mode138 };139140 //UPDATE action141 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 mode153 };154155 //DELETE action156 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 };161162 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 before171 getRowId: (row) => row.id,172 muiToolbarAlertBannerProps: isLoadingUsersError173 ? {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 depth185 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 <IconButton212 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 <Button239 startIcon={<PersonAddAltIcon />}240 variant="contained"241 onClick={() => {242 setCreatingRowIndex(table.getRowModel().rows.length); //create new row at bottom of table243 table.setCreatingRow(true);244 }}245 >246 Create New User247 </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 });261262 return <MaterialReactTable table={table} />;263};264265//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 here272 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call273 return Promise.resolve();274 },275 //client side optimistic update276 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 demo301 });302}303304//READ hook (get users from api)305function useGetUsers() {306 return useQuery<User[]>({307 queryKey: ['users'],308 queryFn: async () => {309 //send api request here310 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call311 return Promise.resolve(fakeData);312 },313 refetchOnWindowFocus: false,314 });315}316317//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 here324 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call325 return Promise.resolve();326 },327 //client side optimistic update328 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 demo336 });337}338339//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 here346 await new Promise((resolve) => setTimeout(resolve, 1000)); //fake api call347 return Promise.resolve();348 },349 //client side optimistic update350 onMutate: (userId: string) => {351 queryClient.setQueryData(['users'], (prevUsers: any) => {352 const newUsers: User[] = JSON.parse(JSON.stringify(prevUsers));353 //remove user354 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 demo369 });370}371372//react query setup in App.tsx373const ReactQueryDevtoolsProduction = lazy(() =>374 import('@tanstack/react-query-devtools/build/modern/production.js').then(375 (d) => ({376 default: d.ReactQueryDevtools,377 }),378 ),379);380381const queryClient = new QueryClient();382383export default function App() {384 return (385 <QueryClientProvider client={queryClient}>386 <Example />387 <Suspense fallback={null}>388 <ReactQueryDevtoolsProduction />389 </Suspense>390 </QueryClientProvider>391 );392}393394const validateRequired = (value: string) => !!value.length;395396function 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}404405function 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