javascriptreactjstypescriptformikjotai

Formik state change


I'm currently working on a React application that involves a lot of calculations. I'm using Formik for form management and lodash for some utility functions. Here is a snippet of my code:

import { useEffect, useRef } from 'react';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';
import FormikControl from './FormikControl';
import { Field, ErrorMessage, FieldArray } from 'formik';
import DeleteIcon from '@material-ui/icons/Delete';
import { Box, Button, Grid } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import productData from '../../Data/products.json';
import { INITIAL_VALUES } from 'src/utils/utils';
import { useFormikContext } from 'formik';
import _ from 'lodash';
import { convertNumberToWords } from 'src/services/services';

interface Column {
  id: 'Product' | 'Quantity' | 'Unit Price' | 'Amount' | 'Action';
  label: string;
  minWidth?: number;
  align?: any;
  format?: (value: number) => string;
}

const columns: readonly Column[] = [
  { id: 'Product', label: 'Product', minWidth: 170 },
  { id: 'Quantity', label: 'Quantity', minWidth: 100, align: 'center' },
  {
    id: 'Unit Price',
    label: 'Unit Price',
    minWidth: 170,
    align: 'center',
    format: (value: number) => value.toLocaleString('en-US')
  },
  {
    id: 'Amount',
    label: 'Amount',
    minWidth: 100,
    align: 'center',
    format: (value: number) => value.toLocaleString('en-US')
  },
  {
    id: 'Action',
    label: 'Action',
    minWidth: 100,
    align: 'center',
    format: (value: number) => value.toFixed(2)
  }
];

const generateId = () => {
  return Date.now().toString(36) + Math.random().toString(36).substring(2);
};

const FormikTable = (props) => {
  const { label, name, values, ...rest } = props;
  const handleUnitsChange = (index, units) => {
    const unitPrice = Number(values[index].unitPrice);
    const unitTotal = parseFloat((units * unitPrice).toFixed(2));
    formik.setFieldValue(`${name}.${index}.units`, units);
    formik.setFieldValue(`${name}.${index}.unitTotal`, unitTotal);
  };

  const handleProductChange = (index, product) => {
    formik.setFieldValue(`${name}.${index}.name`, product);
    formik.setFieldValue(`${name}.${index}.unitPrice`, product.price);
  };

  const handleUnitPriceChange = (index, unitPrice) => {
    const units = Number(values[index].units);
    const unitTotal = parseFloat((units * unitPrice).toFixed(2));
    formik.setFieldValue(`${name}.${index}.unitPrice`, unitPrice);
    formik.setFieldValue(`${name}.${index}.unitTotal`, unitTotal);
  };

  const handleUnitTotalChange = (index, unitTotal) => {
    const units = Number(values[index].units);
    const unitPrice =
      units !== 0 ? parseFloat((unitTotal / units).toFixed(2)) : 0;
    formik.setFieldValue(`${name}.${index}.unitTotal`, unitTotal);
    formik.setFieldValue(`${name}.${index}.unitPrice`, unitPrice);
  };

  const formik = useFormikContext();

  const calculateTotal = (products) => {
    return products.reduce(
      (total, product) => total + Number(product.unitTotal),
      0
    );
  };

  const calculateSubTotal = (products) => {
    return products.reduce(
      (total, product) =>
        total + Number(product.unitPrice) * Number(product.units),
      0
    );
  };

  const debouncedSave = useRef(
    _.debounce((values) => {
      values.forEach((product, index) => {
        const unitTotal = Number(product.unitPrice) * Number(product.units);
        formik.setFieldValue(`${name}.${index}.unitTotal`, unitTotal);
      });
    }, 100)
  ).current;

  useEffect(() => {
    const subtotal = parseFloat(calculateSubTotal(values).toFixed(2));
    formik.setFieldValue('subTotal', Number(subtotal));

    const taxRate = formik.values.taxRate;
    const tax = parseFloat(((taxRate / 100) * subtotal).toFixed(2));
    formik.setFieldValue('totalTax', Number(tax));

    const discountRate = formik.values.discountRate;
    const discount = parseFloat(((discountRate / 100) * subtotal).toFixed(2));
    formik.setFieldValue('totalDiscount', Number(discount));

    const total = parseFloat((subtotal + tax - discount).toFixed(2));
    formik.setFieldValue('total', Number(total));

    debouncedSave(values);
  }, [values, formik.values.taxRate, formik.values.discountRate]);

  convertNumberToWords(123);

  return (
    <FieldArray name={name}>
      {({ insert, remove, push, setFieldValue }) => {
        return (
          <>
            {
              <TableContainer sx={{ maxHeight: 440 }}>
                <Table stickyHeader aria-label="sticky table">
                  <TableHead>
                    <TableRow>
                      {columns.map((column) => (
                        <TableCell
                          key={column.id}
                          align={column.align}
                          style={{ minWidth: column.minWidth }}
                        >
                          {column.label}
                        </TableCell>
                      ))}
                    </TableRow>
                  </TableHead>
                  <TableBody>
                    {values.map((product, index) => (
                      <TableRow key={product.id}>
                        <TableCell>
                          <FormikControl
                            control="autocomplete"
                            type="text"
                            name={`${name}.${index}.name`}
                            options={productData}
                            getOptionLabel={(option: any) => option?.name}
                            onChange={(e, product) =>
                              handleProductChange(index, product)
                            }
                          />
                        </TableCell>
                        <TableCell>
                          <FormikControl
                            control="input"
                            type="number"
                            name={`${name}.${index}.units`}
                            onChange={(e) =>
                              handleUnitsChange(index, e.target.value)
                            }
                          />
                        </TableCell>
                        <TableCell>
                          <FormikControl
                            control="input"
                            type="number"
                            name={`${name}.${index}.unitPrice`}
                            defaultValue={values[index].name?.price}
                            onChange={(e) =>
                              handleUnitPriceChange(index, e.target.value)
                            }
                          />
                        </TableCell>
                        <TableCell>
                          <FormikControl
                            control="input"
                            type="number"
                            name={`${name}.${index}.unitTotal`}
                            onChange={(e) =>
                              handleUnitTotalChange(index, e.target.value)
                            }
                          />
                        </TableCell>
                        <TableCell>
                          <Button onClick={() => remove(index)}>
                            <DeleteIcon />
                          </Button>
                        </TableCell>
                      </TableRow>
                    ))}
                  </TableBody>
                  <Button
                    onClick={() =>
                      push({
                        id: generateId(),
                        name: {},
                        units: 0,
                        unitPrice: 0,
                        unitVat: 0,
                        unitTotal: 0
                      })
                    }
                  >
                    Add Row
                  </Button>
                </Table>
              </TableContainer>
            }
          </>
        );
      }}
    </FieldArray>
  );
};

export default FormikTable;

In this code, I'm handling changes to units, product, unit price, and unit total. I'm also calculating the subtotal, tax, discount, and total. I'm using lodash's debounce function to delay the calculation until the user has finished inputting values which in turn is making the TextField experience super slow.

Meanwhile here is the code for Input component, which is referred to as <FormikControl control="input" .... /> in the code above


import React from 'react';
import { Field, ErrorMessage } from 'formik';
import { TextField, InputLabel } from '@mui/material';

const FormikInput = (props) => {
  const { label, defaultValue, name, ...rest } = props;
  return (
    <Field name={name}>
      {({ field, form }) => {
        return (
          <>
            <InputLabel
              style={{ color: ' #5A5A5A', marginBottom: '5px' }}
              htmlFor={name}
            >
              {label}
            </InputLabel>

            <TextField
            fullWidth
              id={name}
              {...field}
              {...rest}
              value={defaultValue}
              error={form.errors[name] && form.touched[name]}
              helperText={<ErrorMessage name={name} />}
              // InputProps={{
              //   style: { height: '40px', borderRadius: '5px' },
              // }}
            />
          </>
        );
      }}
    </Field>
  );
};

export default FormikInput;

I am using Jotai as my state management library, but I cannot wrap my head around how to use formik with Jotai state management library.

While this code works, I'm wondering if there's a more efficient or cleaner way to handle these calculations. And manage a cleaner code. Specifically, I'm interested in alternatives to the way I'm handling changes and calculating totals.

Any suggestions or insights would be greatly appreciated.


Solution

  • My main suggestion would be to replace Formik with react-hook-form.

    It is much faster than Formik, as it does a better job at isolating component renders to avoid re-rendering the whole form when a single field changes, using subscriptions and uncontrolled forms (instead of controlled ones).

    Also, some additional feedback specific to your form:

    Edit:

    Regarding what you asked in the comment about how/where to calculate subTotal, totalTax, totalDiscount and total, assuming you want to show them after the form, you have a few options:

    One option is to simply calculate them on each render (potentially using useMemo):

    const {
      subtotal,
      totalTax,
      totalDiscount,
      total,
    } = (() => {
      const subTotal = calculateSubTotal(values);
      const totalTax = (taxRate / 100) * subtotal;
      const totalDiscount = (discountRate / 100) * subtotal;
      const total = subtotal + tax - discount;
    
      return { subTotal, totalTax, totalDiscount, total }
    }, [values, taxRate, discountRate]);
    
    return (
      <>
        <Form>
           ...
        </Form>
    
        <div>{ subTotal }</div>
        <div>{ totalTax }</div>
        <div>{ totalDiscount }</div>
        <div>{ total }</div>
      </>    
    )
    

    This might be just fine if the performance improvements you gain from using react-hook-form are enough and the products list is not too long.

    Alternatively, you could use React's 18 useTransition() or or useDeferredValue() so that React decides when to re-calculate and re-render those values without the UI feeling unresponsive.

    They are similar to the debounce you were using, but this way React decides when or when not to compute those updates based on other remaining workload (re-rendering the list/form will be higher priority than updating the totals) and how much time it takes to do so (you'll probably notice no delay if the list is short, as opposed to debouce, which always introduces the same delay).

    Here's an example with useTransition():

    const [isPending, startTransition] = useTransition();
    
    const [{
      subtotal,
      totalTax,
      totalDiscount,
      total,
    }, setResult] = useState({
      subtotal: 0,
      totalTax: 0,
      totalDiscount: 0,
      total: 0,
    });
    
    useEffect(() => {
      startTransition(() => {
        const subTotal = calculateSubTotal(values);
        const totalTax = (taxRate / 100) * subtotal;
        const totalDiscount = (discountRate / 100) * subtotal;
        const total = subtotal + tax - discount;
    
        setResult({ subTotal, totalTax, totalDiscount, total })
      });
    }, [values, taxRate, discountRate]);
    
    return (
      <>
        <Form>
           ...
        </Form>
    
        <div>{ subTotal }</div>
        <div>{ totalTax }</div>
        <div>{ totalDiscount }</div>
        <div>{ total }</div>
      </>    
    )
    

    And here's an example with useDeferredValue():

    return (
      <>
        <Form>
           ...
        </Form>
    
        <CartTotal
          values ={ values }
          taxRate={ taxRate }
          discountRate={ discountRate } />
      </>    
    )
    
    const CartTotal = ({
      values: valuesProp,
      taxRate: taxRateProp,
      discountRate: discountRateProp,
    }) => {
      const values = useDeferredValue(valuesProp);
      const taxRate = useDeferredValue(taxRateProp);
      const discountRate = useDeferredValue(discountRateProp);
    
      const {
        subtotal,
        totalTax,
        totalDiscount,
        total,
      } = (() => {
        const subTotal = calculateSubTotal(values);
        const totalTax = (taxRate / 100) * subtotal;
        const totalDiscount = (discountRate / 100) * subtotal;
        const total = subtotal + tax - discount;
    
        return { subTotal, totalTax, totalDiscount, total }
      }, [values, taxRate, discountRate]);
    
      return (
        <div>
          <div>{ subTotal }</div>
          <div>{ totalTax }</div>
          <div>{ totalDiscount }</div>
          <div>{ total }</div>
        </div>    
      )
    }
    

    You could also try using these methods in your original code with Formik, but I think your issue mostly comes from the continuous re-rendering of the whole FieldArray (due to how Formik works internally), so I would not expect this to improve things too much all by itself.