How do you prevent a re-created React component from losing state? - reactjs

The example in this sandbox is a contrived example, but it illustrates the point.
Clicking the "Add column" button is supposed to add a new column. It works the first time, but doesn't work after that. You'll notice from the log that this issue has to do with the fact that columns is always in its original state. Therefore, a column is always being added to this original state, not the current state.
I imagine this issue is related to the fact that the column header is being re-created on each call to renderHeader, but I'm unsure about how to pass the state to the newly created header component.

There are 2 ways to update your state:
The first way:
setColumns(newColumns)
The second way:
setColumns(columns => newColumns)
You should use the second way because the new state depend on the current state.
onClick={() => {
setColumns((columns) => {
console.log(columns.map((column) => column.field));
const newColumnName = `Column ${columns.length}`;
const newColumns = [...columns];
newColumns.splice(
columns.length - 1,
0,
createColumn(newColumnName)
);
return newColumns;
});
}}
Your component:
import React, { useState } from "react";
import { DataGrid } from "#mui/x-data-grid";
import Button from "#mui/material/Button";
import "./styles.css";
export default function App() {
const [rows] = useState([
{ id: 1, "Column 1": 1, "Column 2": 2 },
{ id: 2, "Column 1": 3, "Column 2": 3 },
{ id: 3, "Column 1": 4, "Column 2": 5 }
]);
const createColumn = (name) => {
return {
field: name,
align: "center",
editable: true,
sortable: false
};
};
const [columns, setColumns] = useState([
createColumn("Column 1"),
createColumn("Column 2"),
{
field: "Add a split",
width: 150,
sortable: false,
renderHeader: (params) => {
return (
<Button
variant="contained"
onClick={() => {
setColumns((columns) => { // <== use callback
console.log(columns.map((column) => column.field));
const newColumnName = `Column ${columns.length}`;
const newColumns = [...columns];
newColumns.splice(
columns.length - 1,
0,
createColumn(newColumnName)
);
return newColumns;
});
}}
>
Add column
</Button>
);
}
}
]);
return (
<div className="App">
<DataGrid
className="App-data-grid"
rows={rows}
columns={columns}
disableSelectionOnClick
disableColumnMenu
/>
</div>
);
}
https://codesandbox.io/s/mui-sandbox-forked-1im0p?file=/src/App.js:0-1562

I think this has to do with the way the closure for (params) => {...} captures the value of columns. At the same time, you have this strange circular thing going on: useState() returns columns and also uses columns in one of its parameters. You will need to find a way to break this circularity.

Related

MUI Datagrid - TypeError: Cannot read properties of undefined (reading 'name')

Im using Material UI's datagrid to show some data in a table, I recently switched over to RTK query from redux for caching and to get rid of the global state management in the project but encountered a problem as mentioned in the title.
The error that I'm currently seeing is TypeError: Cannot read properties of undefined (reading 'name')
This occurs when trying to get data in a nested object, I'm using valueGetter to get to the nested value. The const below shows the columns of the table and where valueGetter is being used (I've removed some of the columns for this example)
const columns = [
{ field: "id", headerName: "ID", width: 70 },
{
field: "App",
headerName: "App",
width: 110,
valueGetter: (params) => params.row.app.name, // <---- Recieving error here
valueFormatter: (params) => capitalise(params.value),
}
];
This is what my component looks like (I've also removed some of the code just for this example):
import React, { useEffect, useState } from "react";
import { DataGrid } from "#mui/x-data-grid";
import { IconButton } from "#material-ui/core";
import { CustomLoadingOverlay } from "../gbl/datagrid/DataGridUtils";
import { capitalise } from "../utils/util";
import {
useGetAppAccountTypesQuery,
useDeleteAccountTypeMutation,
} from "../../services/reducers/accounttypes";
export default ({ appid }) => {
const { data, isLoading, isSuccess } = useGetAppAccountTypesQuery(appid);
const [loading, setLoading] = useState(true);
console.log(data, isLoading, isSuccess);
// Testing only
useEffect(() => {
if (isSuccess) setLoading(false);
}, [isLoading]);
const columns = [
{ field: "id", headerName: "ID", width: 70 },
{
field: "App",
headerName: "App",
width: 110,
valueGetter: (params) => params.row.app.name,
valueFormatter: (params) => capitalise(params.value),
}
];
return (
<>
<div
style={{
height: 190,
width: "100%",
backgroundColor: "white",
marginTop: 40,
borderRadius: 4,
}}
>
<DataGrid
loading={loading}
rows={data}
columns={columns}
components={{
LoadingOverlay: CustomLoadingOverlay,
}}
hideFooter
density="compact"
pageSize={6}
rowsPerPageOptions={[5]}
checkboxSelection
disableSelectionOnClick
></DataGrid>
</div>
</>
);
};
The component re-renders a couple of times, from each render the console.log(data, isLoading, isSuccess) looks like
undefined true false <---- this gets logged 3 times
Array(2) false true
Array(2) false true
this issue seems to be intermittent as it does sometimes work,
Before switching to RTK query, I was using Axios and useEffect to call the endpoints and never received this error, am I doing something wrong here?
Any help would be appreciated.
I would say there is nothing to do with RTK-Q itself here, it's just a common JS problem\question, that should be narrowed to null-check.
It's normal that in some conditions you may have an undefined result, and it should be treated in the code. There may be several options:
The most right way is to just return an empty object or loading state like spinner\skeleton, when data is undefined and\or data isLoading = true :
const { data, isLoading, isSuccess } = useGetAppAccountTypesQuery(appid);
if (!data || isLoading) return <>Skeleton or Spinner</>;
In general - use a null-safe ? accessor:
valueGetter: (params) => params?.row?.app?.name,
Set the default value for data with some object structure that should match your default state for proper column config:
const DEFAULT_DATA = {
row: {
app: {
name: ""
}
}
}
const { data = DEFAULT_DATA, isLoading, isSuccess } = useGetAppAccountTypesQuery(appid);
Just do nothing during the data loading, when data is undefined:
const columns = data ? [...your column mapping...] : [];
You can use them in combination, achieving the expected outcome during the loading time.
Take care of the selection model. Namely: the first time you click on a row or cell the selection model is an array with one item: the index of the selected row. If you click again on the same row the selection model is empty (the row is unselected)
selectionModel={selectionModel}
onSelectionModelChange={(selection) => {
const selectionSet = new Set(selectionModel);
const result = selection.filter((s) => !selectionSet.has(s))
setSelectionModel(result)
if(result.length > 0)
// save de selection model on click on a row
setRecursoSelected(rows[result[0]])
else
// after clicking the same row (unselected)
setRecursoSelected({})
}}

react-big-calendar doesn't show events

I'm using react-big-calendar to create and update event times by dragging them.
I'm using useState to handle the events, and the events themselves are static.
The problem is, they're not loading at all.
And I'm getting the following message in the console:
"Warning: Failed prop type: Invalid prop events[0] of type array supplied to Calendar, expected object."
If anyone could help, I'll appreciate it.
Thanks in advance.
import { useEffect, useState } from 'react';
import { Calendar, dateFnsLocalizer } from 'react-big-calendar';
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
import moment from 'moment';
import { format, parse, startOfWeek, getDay } from 'date-fns';
import enUS from 'date-fns/locale/en-US';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
const DnDCalendar = withDragAndDrop(Calendar);
const locales = {
'en-US': enUS,
};
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek,
getDay,
locales
});
const initialEvents = [
{
id: 0,
title: "All Day Event very long title",
allDay: true,
start: new Date(2015, 3, 0),
end: new Date(2015, 3, 1)
},
{
id: 1,
title: "Long Event",
start: new Date(2015, 3, 7),
end: new Date(2015, 3, 10)
},
];
const EventComponent = ({ start, end, title }) => {
return (
<>
<p>{title}</p>
<p>{start}</p>
<p>{end}</p>
</>
);
};
const EventAgenda = ({ event }) => {
return (
<span>
<em style={{ color: "magenta" }}>{event.title}</em>
<p>{event.desc}</p>
</span>
);
};
const HomePage = () => {
const [events, setEvents] = useState([initialEvents]);
const onEventDrop = ({ event, start, end, allDay }) => {
console.log("event clicked");
console.log(start, event, end, allDay);
};
const addEvent = ({ event, start, end, allDay }) => {
const newEvent = {
id: events.length,
title: "New event",
start: new Date(new Date(start).setHours(new Date().getHours() - 3)),
end: new Date(new Date(end).setHours(new Date().getHours() + 3)),
desc: "This is a new event"
}
setEvents(state => [ ...state, newEvent ]);
};
return (
<DnDCalendar
defaultView='week'
selectable
events={events}
startAccessor="start"
endAccessor="end"
defaultDate={moment().toDate()}
min={new Date(2008, 0, 1, 1, 0)} // 8.00 AM
max={new Date(2008, 0, 1, 23, 59)}
localizer={localizer}
toolbar
resizable
onEventDrop={onEventDrop}
onSelectSlot={addEvent}
onSelectEvent={event => alert(event.desc)}
components={{
event: EventComponent,
agenda: {
event: EventAgenda
}
}}
/>
)
}
export default HomePage
as you mentioned "And I'm getting the following message in the console: "Warning: Failed prop type: Invalid prop events[0] of type array supplied to Calendar, expected object." you have to pass "initialEvents" directly to useState instead of "array of array"
const [events, setEvents] = useState(initialEvents);
You need to change few things.
First, since all your events are in 2015, replace this line of code :
defaultDate={moment().toDate()}
with :
defaultDate={new Date(2015, 3, 1)}
Second, as mentioned in previous answer, since your initialEvents is already array, you set to be initial state like this :
const [events, setEvents] = useState(initialEvents); // you don't need extra []
With these two correction, you shoud be able to see event with tittle 'All day Event very long tittle' on first page, and second event on next page in week view.

Data not displayed after rows updated in Material Ui Datagrid

This component is used to display the users. Once a new user is added from another component usersUpdated gets toggled and a call is made to backend to fetch all the users again which contains the newly added user and display in the Datagrid. But the datagrid does not display any record and distorts the datagrid UI. If the page is refreshed or some other action is performed in Datagrid like changing the pageSize displays all the records properly.
const UsersDisplayTable = (props) => {
const usersUpdated = props.usersUpdated;
const [columns, setColumns] = useState(
[
{
field: 'email',
headerName: 'Email',
align: "left",
headerAlign: "left",
flex: 1,
filterable: true
},
{
field: 'dateOfBirth',
headerName: 'Date Of Birth',
align: "center",
headerAlign: "center",
flex: 0.75,
filterable: false,
sortable: false,
valueFormatter: (params) => {
const valueFormatted = moment(
new Date(params.row.dateOfBirth)).format(
'DD MMM YYYY');
return `${valueFormatted}`;
}
},
{
field: "actions",
headerName: "Actions",
sortable: false,
filterable: false,
align: "center",
headerAlign: "center",
flex: 0.75,
renderCell: (params) => {
return (
<>
<EditUserIcon
onClick={(e) => props.editUser(
e, params.row)}
title='Edit'/>
</>
);
}
}
]
);
const [allUsers, setAllUsers] = useState([]);
useEffect(() => {
axios
.get("/get-all-users")
.then(data => {
setAllUsers(data.data.data)
}).catch(error => {})
}, [usersUpdated])
return (
<>
<DataGrid
sortingOrder={["desc", "asc"]}
rows={allUsers} columns={columns}
disableSelectionOnClick
disableColumnSelector />
</>
);
}
export default UsersDisplayTable;
Initial load of datagrid
after adding dynamic row or user
Is this a limitation of Material UI Datagrid?
I was experiencing the same issue using #mui/x-data-grid version 5.0.1.
I was able to get around this issue by setting up a useEffect with a dependency on my rows. Within this use effect I just toggle a state variable which I use for the row height in my grid.
const [rowHeight, setRowHeight] = useState(28);
useEffect(() => {
if (rowHeight === 28) {
setRowHeight(29);
}else {
setRowHeight(28);
}
}, [rows]);
...
<DataGrid
rows={rows}
columns={columns}
pageSize={pgSize}
rowHeight={rowHeight}
...otherProps
/>
I think by changing the height it's triggering a re-render of the grid and its contents.
This solution is a hack to work-around a bug in the code.
I found the same issue on #mui/x-data-grid v5.17.14 (and Next.js 13 if that has anything to do with it)
In my case, the bug was when changing to a new page. I was pushing that change of page to the query params of the URL instead of using state, and then reading from there like this:
export default function usePagination(initialValues?:PaginationOptions) {
const {query, push} = useRouter();
const pageSize = Number(query.pageSize) > 0 ? Number(query.pageSize) : initialValues?.initialPageSize ?? GLOBAL_DEFAULT_PAGE_SIZE;
const page = Number(query.page) > 0 ? Number(query.page) : initialValues?.initialPage ?? 1;
const setPage = (input:number) => push({query:{...query, page:input+1}});
const setPageSize = (input:number) => push({query:{...query, pageSize:input}});
return {page, pageSize, setPage, setPageSize};
}
That doesn't work because datagrid must somehow be checking which parameter caused the re-render, and since this change was coming from a change in URL, it didn't react properly.
Forcing to use a useState for the page fixes the issue:
export default function usePagination(initialValues?:PaginationOptions) {
const {query, push} = useRouter();
const pageSize = Number(query.pageSize) > 0 ? Number(query.pageSize) : initialValues?.initialPageSize ?? GLOBAL_DEFAULT_PAGE_SIZE;
const [page,setPageState] = useState(Number(query.page) > 0 ? Number(query.page) : initialValues?.initialPage ?? 1);
const setPage = (input:number) => {setPageState(input+1); push({query:{...query, page:input+1}})};
const setPageSize = (input:number) => push({query:{...query, pageSize:input}});
return {page, pageSize, setPage, setPageSize};
}

Material-UI: DataGrid server-side pagination issue when setting rowCount

So I just started using Material UI and pretty much I am loving it. Now, we are working on a project that involves data from users, employees and addresses in a specific city here in Philippines and I decided to use a table to display it to the client since I find this much easier. So, this table needs to be paginated, sorted, filtered, etc. and we are doing it in the server's side. Apparently, client needs to send couple of data like { page: 1, page_size: 50, ....} and that's what we did in my react.
The problem that I have right now is I think it is with the DataGrid. I think the table does not re-render the rowCount after fetching the totalRows data in the database. I have the sandbox here (PS: You have to enlarge the output screen to have the rowsPerPageOptions visible.) But as you can notice in there the first time it loads the next arrow is disabled and it does not re-render the time the actual data including the number of rows was loaded. But if you keep navigating like changing the page size it goes available like nothing is wrong.
I'm kind of stuck with this issue right now and I don't even know if I am using it the right way. Any help will be appreciated.
import { useState, useEffect } from "react";
import { DataGrid } from "#material-ui/data-grid";
import { Box } from "#material-ui/core";
const dummyColorsDB = [
{ id: 1, color: "red" },
{ id: 2, color: "green" },
{ id: 3, color: "blue" },
{ id: 4, color: "violet" },
{ id: 5, color: "orange" },
{ id: 6, color: "burgundy" },
{ id: 7, color: "pink" },
{ id: 8, color: "yellow" },
{ id: 9, color: "magenta" },
{ id: 10, color: "random color" },
{ id: 11, color: "another random color" },
{ id: 12, color: "last one" }
];
export default function App() {
const [data, setData] = useState({
loading: true,
rows: [],
totalRows: 0,
rowsPerPageOptions: [5, 10, 15],
pageSize: 5,
page: 1
});
const updateData = (k, v) => setData((prev) => ({ ...prev, [k]: v }));
useEffect(() => {
updateData("loading", true);
setTimeout(() => {
const rows = dummyColorsDB.slice(
(data.page - 1) * data.pageSize,
(data.page - 1) * data.pageSize + data.pageSize
);
console.log(rows);
updateData("rows", rows);
updateData("totalRows", dummyColorsDB.length);
updateData("loading", false);
}, 500);
}, [data.page, data.pageSize]);
return (
<Box p={5}>
<DataGrid
density="compact"
autoHeight
rowHeight={50}
//
pagination
paginationMode="server"
loading={data.loading}
rowCount={data.totalRows}
rowsPerPageOptions={data.rowsPerPageOptions}
page={data.page - 1}
pageSize={data.pageSize}
rows={data.rows}
columns={[{ field: "color", headerName: "Color", flex: 1 }]}
onPageChange={(data) => {
updateData("page", data.page + 1);
}}
onPageSizeChange={(data) => {
updateData("page", 1);
updateData("pageSize", data.pageSize);
}}
/>
</Box>
);
}
When you update the component state by calling multiple times like this:
updateData("rows", rows);
updateData("rowCount", dummyColorsDB.length);
updateData("loading", false);
Your updateData calls setState but because setState executes asynchronously, they are not updated at the same time. In fact, the reason why the pagination doesn't work at the first render is because you set the grid rows before setting its rowCount. My guess is that this is a Material-UI bug after inspecting the codebase. They don't seem to add state.options.rowCount to the dependency array in useEffect so nothing get re-render when you update rowCount later.
This is clearer when you defer each call a little bit. The code below does not work.
// set rows first
updateData("rows", rows);
setTimeout(() => {
// set rowCount later
updateData("rowCount", dummyColorsDB.length);
updateData("loading", false);
}, 100);
But try setting the rowCount first and the pagination works again
// set rowCount first
updateData("rowCount", dummyColorsDB.length);
setTimeout(() => {
updateData("rows", rows);
updateData("loading", false);
}, 100);
Another solution is to update all related state at the same time:
setData((d) => ({
...d,
rowCount: dummyColorsDB.length,
rows,
loading: false
}));
Live Demo

Navigation in DetailsList not possible with Monaco editor

Hi I'm using the DetailsList and I want to be able to move my selection from column to column using tab.
But I came across this issue on Github:
https://github.com/microsoft/fluentui/issues/4690
Arrow keys needs to be used to navigate across the list but unfortunately I'm using a Monaco Editor in the list and the arrow key is blocked inside the Editor...
I would like to know if there is way to disable the List to set the TabIndex to -1
or
if Monaco can release the arrow key when the cursor is at the end of the text (Like a textbox).
I got something working following this rationale:
listen to the onKeydown event on monaco editor
identify the position of the caret
know the total of lines
get the string of a specific line
move the focus out from monaco editor
Knowing these then you can check if the caret is at the end of the last line and move the focus when the user press the right arrow key. I also added the code to check when the caret is at the very beginning and move the focus to the cell to the left.
This is the code I ended up with
import * as React from "react";
import "./styles.css";
import { DetailsList, IColumn } from "#fluentui/react";
import MonacoEditor from "react-monaco-editor";
export default function App() {
const columns: IColumn[] = [
{
key: "name",
minWidth: 50,
maxWidth: 50,
name: "Name",
onRender: (item, index) => (
<input id={`name-row-${index}`} value={item.name} />
)
},
{
key: "type",
minWidth: 200,
name: "Type",
onRender: (item, index) => {
return (
<MonacoEditor
editorDidMount={(editor, monaco) => {
editor.onKeyDown((event) => {
if (event.code === "ArrowRight") {
const { column, lineNumber } = editor.getPosition();
const model = editor.getModel();
if (lineNumber === model?.getLineCount()) {
const lastString = model?.getLineContent(lineNumber);
if (column > lastString?.length) {
const nextInput = document.getElementById(
`default-value-row-${index}`
);
(nextInput as HTMLInputElement).focus();
}
}
}
if (event.code === "ArrowLeft") {
const { column, lineNumber } = editor.getPosition();
if (lineNumber === 1 && column === 1) {
const previousInput = document.getElementById(
`name-row-${index}`
);
(previousInput as HTMLInputElement).focus();
}
}
});
}}
value={item.type}
/>
);
}
},
{
key: "defaultValue",
minWidth: 100,
name: "Default Value",
onRender: (item, index) => (
<input id={`default-value-row-${index}`} value={item.defaultValue} />
)
}
];
const items = [{ name: "name", type: "type", defaultValue: "name" }];
return <DetailsList columns={columns} items={items} />;
}
You can see it working in this codesandbox https://codesandbox.io/s/wild-smoke-vy61m?file=/src/App.tsx
monaco-editor seems to be something quite complex, probably you'll have to improve this code in order to support other interactions (ex: I don't know if this works when code is folded)

Resources