I'm currently rendering an editable table that lets the user bulk edit several user's information at once (See Image). I'm using Material-UI's <TextField/>
and Formik to handle the form's submission and state.
I'm trying to:
- Keep the
<TextField />
's value and Formik's state in sync - Whenever I remove one row (when x is clicked), to reflect the changes in the whole table.
This table comprises generally of approximately 266 input fields. Using onChange
event brings serious performance concerns. Therefore I've had to apply several component wrapping, and memoization to prevent all the input fields from re-rendering every time a single input had changed.
I've been successfully made this work (almost in a good performant way), except whenever I delete a row. The old value seems to remain, while Formik's value does change.
The problem appears to be in how the defaultValue
and value
properties of the <TextField />
work.
The value
property seems to create a controlled component and will reflect 1-on-1 whatever value you pass in there. I've tried setting Formik's field.value
directly into the field. Unfortunately, the value wouldn't update the field as I'm currently using the onBlur event to do it (and will never show the changes). If I were to use the onChange, everything would work, except performance would be garbage as it would update all the fields.
On the other hand, the defaultValue
makes the component uncontrolled. Nonetheless, I'm able to edit the value, and even update Formik's state onBlur
!. There is one problem, though... whenever I delete a row, the value inside the <TextField/>
does not update (But Formik does reflect the change).
It seems that there is some caching going on inside the <TextField />
component, as I've tried logging the field's value, which is the one that I'm currently passing to defaultValue
, and it is showing the changes.
I've also tried:
- Tinkering with
defaultValue
andvalue
- Setting a
useState
hook to work as a middleman between Formik's value and the component's - Removing the memoization.
- Manually implemented the memoization comparison.
And none of them seems to work... What should I do in this case?
For reference, here's the code I'm using:
Here's the textfield I'm currently using:
FormText
import React, { memo } from 'react';
import { useField } from 'formik';
import TextField from '@material-ui/core/TextField';
import { TextProps } from '../../../Fields/TextField/textfield-definitions';
type ComponentProps = TextProps & {
useBlur?: boolean;
errorMessage: string | undefined;
};
export const Component: React.FC<ComponentProps> = memo(props => {
const {
className,
name,
label,
placeholder,
required,
useBlur,
error,
errorMessage,
onChange,
onBlur,
value,
} = props;
// We wrap it so we don't block the heap stack!
// Improves performance considerably
// https://medium.com/trabe/react-syntheticevent-reuse-889cd52981b6
const fireBlur = (e: any) => {
// React removes
e.persist();
window.setTimeout(() => {
if (onBlur) {
onBlur(e);
}
}, 0);
};
const setInnerState = (e: React.ChangeEvent<HTMLInputElement>) => {};
const fireChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.persist();
setInnerState(e);
window.setTimeout(() => {
if (onChange) {
onChange(e);
}
}, 0);
};
return (
<TextField
className={className}
name={name}
label={label}
type={props.type}
placeholder={placeholder}
defaultValue={value}
variant="outlined"
required={required}
error={error}
helperText={<span>{error ? errorMessage : ''}</span>}
onChange={useBlur ? undefined : fireChange}
onBlur={useBlur ? fireBlur : undefined}
/>
);
});
export const SchonText: React.FC<TextProps> = props => {
const [field, meta] = useField(props.name);
const hasError = !!meta.error && !!meta.touched;
return (
<Component
value={field.value}
{...props}
error={hasError}
errorMessage={meta.error}
onChange={field.onChange}
onBlur={field.onChange}
/>
);
};
export default SchonText;
Here are the components that are consuming it:
TableRow
import React, { memo } from 'react';
import { TableRow, TableCell, makeStyles } from '@material-ui/core';
import { Close } from '@material-ui/icons';
import {
FormText,
FormSelect,
FormTextArea,
Button,
} from '../../../../../../components';
import { Student, Gender } from '../../../../../../graphql/types';
import { SelectOption } from '../../../../../../components/Fields/Select/select-definitions';
type BulkAddTableRowProps = {
student: Student;
index: number;
deleteStudent: (index: number) => void;
};
const useStyles = makeStyles(theme => ({
root: {
padding: `0px`,
},
}));
const selectOptions: SelectOption[] = [
{
label: 'M',
value: Gender.Male,
},
{
label: 'F',
value: Gender.Female,
},
];
const Component: React.FC<BulkAddTableRowProps> = props => {
const styles = useStyles();
const { student, index } = props;
const deleteStudent = () => props.deleteStudent(index);
return (
<TableRow className={styles.root} hover={true}>
<TableCell>{index + 1}</TableCell>
<TableCell className={styles.root}>
<FormText
name={`students[${index}].name.firstName`}
value={student.name.firstName}
useBlur={true}
/>
</TableCell>
<TableCell>
<FormText
name={`students[${index}].name.lastName`}
value={student.name.lastName}
useBlur={true}
/>
</TableCell>
<TableCell>
<FormSelect
name={`students[${index}].gender`}
value={student.gender}
options={selectOptions}
/>
</TableCell>
<TableCell>
<FormText
type="email"
name={`students[${index}].email`}
value={student.email}
useBlur={true}
/>
</TableCell>
<TableCell>
<FormText
type="date"
name={`students[${index}].birthDate`}
value={student.birthDate}
useBlur={true}
/>
</TableCell>
<TableCell>
<FormTextArea
name={`students[${index}].allergies`}
value={student.allergies}
useBlur={true}
/>
</TableCell>
<TableCell>
<FormTextArea
name={`students[${index}].diseases`}
value={student.diseases}
useBlur={true}
/>
</TableCell>
<TableCell>
<Button onClick={deleteStudent}>
<Close />
</Button>
</TableCell>
</TableRow>
);
};
function shouldRemainTheSame(
prevProps: BulkAddTableRowProps,
newProps: BulkAddTableRowProps,
): boolean {
const prevStudent = prevProps.student;
const newStudent = newProps.student;
const isNameTheSame = Object.keys(prevStudent.name).every(key => {
return prevStudent.name[key] === newStudent.name[key];
});
const isStudentTheSame = Object.keys(prevStudent)
.filter(x => x !== 'name')
.every(key => prevStudent[key] === newStudent[key]);
return (
isNameTheSame && isStudentTheSame && prevProps.index === newProps.index
);
}
export const BulkAddTableRow = memo(Component, shouldRemainTheSame);
export default BulkAddTableRow;
StudentBulkTableView
import React, { memo } from 'react';
import {
FieldArray,
FieldArrayRenderProps,
getIn,
useFormikContext,
} from 'formik';
import { Student, Gender } from '../../../../graphql/types/index';
import {
Paper,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
makeStyles,
} from '@material-ui/core';
import { Button, Select } from '../../../../components';
import { SelectOption } from '../../../../components/Fields/Select/select-definitions';
import { emptyStudent, BulkAddStudentValues } from '../shared';
import BulkAddTableRow from './components/TableRow/index';
type ComponentProps = {
push: (obj: any) => void;
remove: (index: number) => undefined;
students: Student[];
setFieldValue: (
field: 'students',
value: any,
shouldValidate?: boolean | undefined,
) => void;
};
const selectOptions: SelectOption[] = [
{
label: 'M',
value: Gender.Male,
},
{
label: 'F',
value: Gender.Female,
},
];
const useStyles = makeStyles(theme => ({
root: {
padding: `0px`,
},
}));
const Component: React.FC<ComponentProps> = memo(props => {
const styles = useStyles();
const { students, push, remove, setFieldValue } = props;
function deleteStudent(index: number) {
if (!window.confirm('¿Desea borrar este estudiante?')) {
return;
}
remove(index);
}
const addStudent = () => push(emptyStudent());
const selectAllOptions = (evt: React.ChangeEvent<HTMLInputElement>) => {
students.forEach(student => (student.gender = evt.target.value as Gender));
console.log(students);
setFieldValue('students', students);
};
return (
<>
Cambiar el género a todos los estudiantes:{' '}
<Select
name="select_all"
options={selectOptions}
onChange={selectAllOptions}
/>{' '}
<br />
<Paper style={{ width: '100%' }}>
<Table style={{ width: '100%', padding: 'root' }}>
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>Nombre</TableCell>
<TableCell>Apellido</TableCell>
<TableCell>Género</TableCell>
<TableCell>Email</TableCell>
<TableCell>Cumpleaños</TableCell>
<TableCell>Alergias</TableCell>
<TableCell>Enfermedades</TableCell>
<TableCell>Acción</TableCell>
</TableRow>
</TableHead>
<TableBody>
{students.map((student, index) => (
<BulkAddTableRow
key={`${student.name}-${index}`}
student={student}
deleteStudent={deleteStudent}
index={index}
/>
))}
<TableRow>
<TableCell colSpan={8}></TableCell>
<TableCell>
<Button onClick={addStudent}>+</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Paper>
</>
);
});
export const StudentBulkTableView: React.FC = props => {
const { setFieldValue } = useFormikContext<BulkAddStudentValues>();
return (
<FieldArray name="students">
{({ remove, push, form }: FieldArrayRenderProps) => {
const students = getIn(form.values, 'students') as Student[];
return (
<Component
setFieldValue={setFieldValue}
remove={remove}
push={push}
students={students}
/>
);
}}
</FieldArray>
);
};
export default StudentBulkTableView;
P.S: I've excluded the <FormTextArea />
component as it's exactly the same as the <FormText />
component.