1
votes

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.

Bulk Edit

I'm trying to:

  1. Keep the <TextField />'s value and Formik's state in sync
  2. 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 and value
  • 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.

1

1 Answers

1
votes

Based on the behavior you describe, it sounds like there might be a problem with the key you're using for each of the rows.

<BulkAddTableRow
  key={`${student.name}-${index}`}

It looks like student.name is an object, which means your keys would be "[object Object]-0", "[object Object]-1", etc. Index-based keys will cause problems when deleting rows, because React won't know that the value for that index has changed.

Here's an article describing the problem: https://medium.com/@robinpokorny/index-as-a-key-is-an-anti-pattern-e0349aece318

You can console.log the key for each of the rows, and if they are [object-Object] plus the index, you can do something like this:

<BulkAddTableRow
  key={`${student.name.firstName}-${student.name.lastName}`}