Formula Not updating automatically when new row added

When a new row is added formula is not updating automatically. I’m using Zustand store to manage the data and styles.

Page:

import { Container } from "@/components/common/container";
import { useCallback, useEffect, useRef, useState } from "react";
import { useUnsavedChangesProtection } from "@/hooks/use-unsaved-changes-protection";
import { BOMItem, RowStylesMap } from "@/types";
import { HotTableRef } from "@handsontable/react-wrapper";
import DataTable from "@/components/bom/tables/data-table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import SearchColumns from "@/components/bom/search-columns";
import { Button } from "@/components/ui/button";
import { FileOutput, SaveIcon, Table2Icon } from "lucide-react";
import { useStreamData } from "../../hooks/use-stream-data";
import {
  convertToCsv,
  exportCSV,
  getNewRow,
  transformDataToObjectsAndRemoveEmpty,
} from "@/components/bom/functions";
import { colHeaders, ColumnConfig } from "@/components/bom/config/columns";
import { axiosCall } from "@/lib/axios";
import { useQuery } from "@tanstack/react-query";
import SaveTemplateModal from "@/components/bom/save-template-modal";
import ShowHideColumns from "@/components/bom/tables/show-hide-columns";
import { useBomStore } from "@/stores/use-bom-store";
import { toast } from "sonner";
import ContentLoadingSpinner from "@/components/common/content-loading-spinner";
import { useClearTableFilters } from "@/components/bom/hooks/use-clear-table-filters";
import { debounce } from "@/lib/helpers";
import { useBomColumnsStore } from "@/stores/use-bom-columns-store";
import TableFilters from "@/components/bom/tables/table-filters";

export default function BOMManagementPage() {
  const tableRef = useRef<HotTableRef>(null);
  const { bom, styles, setBom, setStyles, addBomItem } = useBomStore();
  const { hiddenColumns, initHiddenColumns } = useBomColumnsStore();
  const [isSaving, setIsSaving] = useState(false);
  const [hasChanged, setHasChanged] = useState(false);
  const clearFilters = useClearTableFilters({ hot: tableRef.current?.hotInstance });
  const [showSaveModal, setShowSaveModal] = useState(false);
  const [filterValues, setFilterValues] = useState({
    col: 0,
    value: "",
  });

  useEffect(() => {
    const cols = localStorage.getItem("bom-columns-store");
    if (cols) {
      const parsed = JSON.parse(cols);
      if (parsed.state && Array.isArray(parsed.state.hiddenColumns)) {
        initHiddenColumns(parsed.state.hiddenColumns);
      }
    }
  }, [initHiddenColumns]);

  /**
   ===============================================================
   * Fetch data from the stream and styles from the API
   * Use useStreamData hook to get data from the stream
   * Use useQuery to fetch styles from the API
   ===============================================================
   */
  const { data, loading, error } = useStreamData(`/items`);
  const { data: stylesData, refetch } = useQuery<RowStylesMap, Error>({
    queryKey: ["styles"],
    queryFn: async () => {
      const response = await axiosCall({ method: "GET", urlPath: `/items/styles` });
      return response.data;
    },
    refetchOnWindowFocus: false,
  });

  useEffect(() => {
    if (stylesData) {
      setStyles(stylesData.data);
    }
  }, [setStyles, stylesData]);

  /**
   ===============================================================
    * @function applyFilters
    * Apply filters to the table
    * This function is called when the user clicks the apply button
    * @function resetTableFilter 
    * resets the filter values to default and removes the filter from the table
   ===============================================================
   */
  const applyFilters = useCallback(() => {
    if (tableRef.current && tableRef.current.hotInstance) {
      const hot = tableRef.current.hotInstance;
      const filters = hot.getPlugin("filters");
      filters.clearConditions();

      if (filterValues.value !== "") {
        filters.addCondition(filterValues.col, "contains", [filterValues.value]);
      }
      filters.filter();
      hot.render();
    }
  }, [filterValues]);

  useEffect(() => {
    const debouncedApply = debounce(() => {
      applyFilters();
    }, 500);
    debouncedApply();
  }, [applyFilters, filterValues]);

  function resetTableFilter() {
    if (tableRef.current && tableRef.current.hotInstance) {
      clearFilters();
    }
    setFilterValues({ col: 0, value: "" });
  }

  /**
   ===============================================================
   * Add a new row to the table
   * This function is called when the user clicks the add row button
   ===============================================================
   */
  function addNewRow() {
    const newRowId = bom.length > 0 ? +bom[bom.length - 1].id + 1 : 1;
    const newRow: BOMItem = getNewRow(newRowId);

    addBomItem(newRow, newRowId);
    setHasChanged(true); // Set the state to true when a new row is added
  }

  /**
   ===============================================================
   * Save the table data
   ===============================================================
   */
  async function handleSave() {
    if (tableRef.current && tableRef.current.hotInstance) {
      const hot = tableRef.current?.hotInstance;
      const data = hot?.getSourceDataArray();

      if (!colHeaders || colHeaders.length === 0) {
        toast.error("Column headers are missing. Cannot generate CSV.");
        return;
      }

      setIsSaving(true);
      const stylesData = transformDataToObjectsAndRemoveEmpty(styles);
      try {
        const csvString = convertToCsv(colHeaders, data);
        const stylesRowsAsArrayOfArrays = stylesData.rows.map((obj) =>
          stylesData.headers.map((header) => obj[header])
        );

        const csvStringStyles = convertToCsv(
          stylesData.headers,
          stylesRowsAsArrayOfArrays
        );

        const csvBlob = new Blob([csvString], { type: "text/csv" });
        const csvBlobStyles = new Blob([csvStringStyles], { type: "text/csv" });

        const formData = new FormData();
        formData.append(
          "csvFile",
          csvBlob,
          `bom-upload-${new Date().toISOString().split("T")[0]}.csv`
        );
        formData.append(
          "csvFile",
          csvBlobStyles,
          `styles-bom-${new Date().toISOString().split("T")[0]}.csv`
        );

        const response = await axiosCall({
          method: "POST",
          urlPath: `/items/bulk-upload-csv`,
          data: formData,
          contentType: "multipart/form-data",
        });

        toast.success(
          response.data.message || "Table data uploaded and processed successfully!"
        );
        setIsSaving(false);
        setHasChanged(false);
      } catch (error) {
        setIsSaving(false);
        console.error("Data upload failed:", error);
        toast.error("An unexpected error occurred during data upload.");
      }
    }
  }

  /**
   ===============================================================
   * Update data from stream and handle loading and error
   ===============================================================
   */
  useEffect(() => {
    if (data && data.length > 0) {
      setBom(data[0]);
    }
  }, [data, setBom]);

  /**
   ===============================================================
   * Prevent window from closing if there are unsaved changes
   ===============================================================
   */
  useUnsavedChangesProtection(hasChanged);

  /**
   ===============================================================
   * Handle loading and error
   ===============================================================
   */
  if (loading || error) {
    return (
      <Container>
        {error && <p>Error: {error}</p>}
        {loading && <ContentLoadingSpinner title="Loading Data..." />}
      </Container>
    );
  }

  return (
    <div className="pb-12 px-6 mx-auto">
      <Card className="border-b-0 rounded-b-none -z-10">
        <CardHeader>
          <CardTitle>Bill of Material Items</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="grid grid-cols-1 gap-4 lg:gap-0 lg:grid-cols-[4fr_8fr] justify-between items-end">
            <SearchColumns
              colHeaders={colHeaders}
              filterValues={filterValues}
              setFilterValues={setFilterValues}
              reset={resetTableFilter}
            />
            <div className="flex gap-4 lg:justify-self-end">
              <Button variant="outline" size="md" onClick={() => setShowSaveModal(true)}>
                <SaveIcon className="mr-2 size-4" />
                Save As Template
              </Button>
              <Button
                variant="primary"
                size="md"
                onClick={handleSave}
                disabled={isSaving || !hasChanged}
              >
                <SaveIcon className="mr-2 size-4" />
                {isSaving ? "Saving..." : "Save"}
              </Button>
              <Button
                variant="outline"
                size="md"
                className="border-green-500 text-green-500"
                onClick={() => exportCSV(tableRef)}
              >
                <FileOutput className="mr-2 size-4" />
                Export CSV
              </Button>
              <Button
                variant="outline"
                size="md"
                className="border-slate-900 text-slate-900"
                onClick={addNewRow}
              >
                <Table2Icon className="mr-2 size-4" />
                Add Row
              </Button>
              <ShowHideColumns tableRef={tableRef} />
            </div>
          </div>
          <TableFilters tableRef={tableRef} />
        </CardContent>
      </Card>
      <DataTable
        id="main-bom-table"
        data={bom}
        tableRef={tableRef}
        colHeaders={colHeaders}
        colConfig={ColumnConfig}
        styles={styles}
        setStyles={setStyles}
        setTableData={setBom}
        refetchStyles={refetch}
        setHasChanged={setHasChanged}
        hiddenColumns={hiddenColumns}
      />
      <SaveTemplateModal
        open={showSaveModal}
        onOpenChange={(open) => setShowSaveModal(open)}
      />
    </div>
  );
}

Component:

import { HyperFormula } from "hyperformula";
import { registerAllModules } from "handsontable/registry";
import { HotTable, HotTableRef } from "@handsontable/react-wrapper";
import { RefObject, useCallback, useEffect } from "react";
import { BOMItem, HandleMetaChange, RowStyles } from "@/types";
import { useTheme } from "next-themes";
import useContextMenu from "@/components/bom/tables/use-context-menu";
import { CellChange, RowObject } from "handsontable/common";
import { ColumnRenderer } from "./column-renderer";
import { getColumnByIndex } from "../functions";
import { useBomStore } from "@/stores/use-bom-store";
import { ColConfigType } from "../config/columns";

type DataTableProps = {
  id: string;
  data: BOMItem[];
  tableRef: RefObject<HotTableRef>;
  colHeaders: string[];
  styles: RowStyles;
  colConfig: ColConfigType[];
  hiddenColumns: number[];
  setStyles: (styles: RowStyles) => void;
  setTableData: (data: BOMItem[]) => void;
  refetchStyles: () => void;
  calculateTotalCost?: () => number;
  setHasChanged: (hasChanged: boolean) => void;
};

// REGISTER HANDSONTABLE MODULES
registerAllModules();
const hyperformulaInstance = HyperFormula.buildEmpty({
  licenseKey: "internal-use-in-handsontable",
});

export default function DataTable({
  id,
  data,
  tableRef,
  colHeaders,
  styles,
  colConfig,
  hiddenColumns,
  setStyles,
  calculateTotalCost,
  setHasChanged,
}: DataTableProps) {
  const { theme } = useTheme();
  const { updateBomItem } = useBomStore();
  const { ContextMenuConfig } = useContextMenu({
    tableRef,
  });

  /**
  ===============================================================
  * On Theme change, update the theme of the table
  ===============================================================
  */
  useEffect(() => {
    const hot = tableRef.current.hotInstance;
    if (!hot) return;

    hot.useTheme(theme === "light" ? "ht-theme-main" : "ht-theme-main-dark");
    hot.render();
  }, [tableRef, theme]);

  /**
  ===============================================================
  Handle Table Column Changes
  ===============================================================
  */
  const handleChange = useCallback(
    (change: CellChange[]) => {
      if (!change) return;
      const hot = tableRef.current.hotInstance;

      change.forEach(([_rowIndex, prop, oldVal, newVal]) => {
        if (oldVal == newVal) return;

        const physicalRowIndex = hot.toPhysicalRow(_rowIndex);
        const dataRow: RowObject = hot.getSourceDataAtRow(physicalRowIndex);

        // Update the BOM item
        updateBomItem(physicalRowIndex, dataRow as BOMItem);

        // Calculate and update total cost if quantity or unitCost changes
        if (prop === "quantity" || prop === "unitCost") calculateTotalCost?.();
      });
      setHasChanged(true); // Set the state to true when a cell is changed
    },
    [tableRef, setHasChanged, updateBomItem, calculateTotalCost]
  );

  /**
  ===============================================================
  Handle Table Styles Changes
  ===============================================================
  */
  const updateCellStyle = useCallback(
    (itemId: number, column: string, newStyle: any) => {
      const style = (styles[itemId] = { ...styles[itemId], [column]: newStyle });
      setStyles({ ...styles, [itemId]: style });
    },
    [styles, setStyles]
  );

  const handleMetaChange: HandleMetaChange = useCallback(
    (row, column, key, value) => {
      if (key == "backgroundColor") {
        const colName = getColumnByIndex(column);

        updateCellStyle(row, colName, { backgroundColor: value });
        setHasChanged(true);
      }
    },

    [updateCellStyle, setHasChanged]
  );

  /**
  ===============================================================
  * Handle Remove Row
  ===============================================================
  */
  function handleRemoveRow() {
    setHasChanged(true);

    // Recalculate total cost after removing row
    if (calculateTotalCost) {
      setTimeout(() => {
        calculateTotalCost();
      }, 100);
    }
  }

  return (
    <div className={theme === "light" ? "ht-theme-main" : "ht-theme-main-dark"}>
      <HotTable
        id={id}
        ref={tableRef}
        data={data}
        height={600}
        multiColumnSorting={true}
        filters={true}
        rowHeaders={true}
        headerClassName="htLeft"
        manualRowMove={true}
        autoWrapRow={true}
        autoWrapCol={true}
        manualRowResize={true}
        manualColumnResize={true}
        navigableHeaders={true}
        viewportRowRenderingOffset={50}
        persistentState={true}
        licenseKey="non-commercial-and-evaluation"
        contextMenu={ContextMenuConfig}
        colHeaders={colHeaders}
        dropdownMenu={["filter_by_condition", "filter_by_value", "filter_action_bar"]}
        formulas={{
          engine: hyperformulaInstance,
        }}
        allowInvalid={false}
        afterChange={handleChange}
        afterSetCellMeta={handleMetaChange}
        columns={colConfig.map((col) => ({
          data: col.name,
          type: col.type,
          renderer: function (instance, td, row, col, prop, value) {
            ColumnRenderer(instance, td, row, col, prop, value, styles);
          },
          readOnly: col.readOnly,
        }))}
        hiddenColumns={{ columns: hiddenColumns }}
        afterRemoveRow={handleRemoveRow}
      ></HotTable>
    </div>
  );
}

context menu:

import { ContextMenu, Settings } from "handsontable/plugins/contextMenu";
import { colors } from "@/components/bom/config/colors";
import { RefObject } from "react";
import { HotTableRef } from "@handsontable/react-wrapper";
import { getColumnByIndex, getNewRow } from "../functions";
import { useBomStore } from "@/stores/use-bom-store";

type Props = {
  tableRef: RefObject<HotTableRef>;
};

export default function useContextMenu({ tableRef }: Props) {
  const { addBomItem, addStyle, removeBomItem } = useBomStore();

  function changeCellBgColor(color: string) {
    const hot = tableRef.current?.hotInstance;
    if (!hot) return;

    const selected = hot.getSelectedRangeLast();
    if (!selected) return;

    const startRow = selected.from.row;
    const endRow = selected.to.row;
    const startCol = selected.from.col;
    const endCol = selected.to.col;

    for (let row = startRow; row <= endRow; row++) {
      for (let col = startCol; col <= endCol; col++) {
        hot.setCellMeta(row, col, "backgroundColor", color);

        const currentCell = hot.getCell(row, col);
        currentCell.setAttribute("style", `background-color: ${color} !important;`);
        addStyle(row, getColumnByIndex(col), { backgroundColor: color });
      }
    }

    if (color) {
      const styleId = `custom-bg-style-${color
        .replace("#", "")
        .replace(/[^a-zA-Z0-9]/g, "")}`;
      if (!document.getElementById(styleId)) {
        const style = document.createElement("style");
        style.id = styleId;
        style.textContent = `.custom-bg-${color
          .replace("#", "")
          .replace(/[^a-zA-Z0-9]/g, "")} { background-color: ${color} !important; }`;
        document.head.appendChild(style);
      }
    }

    hot.render();
  }

  const ContextMenuConfig: Settings = {
    items: {
      row_above: {
        name: "Insert row above",
        callback: function (_key, hot) {
          if (hot) {
            const row = hot.at(0).start.row;
            const newRow = getNewRow(row);
            addBomItem(newRow, row);
            this.alter("insert_row_above", row, 1);
          }
        },
      },
      row_below: {
        name: "Insert row below",
        callback: function (_key, hot) {
          if (hot) {
            const row = hot.at(0).start.row;
            const newRow = getNewRow(row);
            addBomItem(newRow, row + 1);
            this.alter("insert_row_below", row + 1, 1);
          }
        },
      },
      remove_row: {
        name: "Remove row",
        callback: function (_key, hot) {
          if (hot) {
            const rowIds = [];
            const startRow = hot.at(0).start.row;
            const endRow = hot.at(0).end.row;

            for (let row = startRow; row <= endRow; row++) {
              rowIds.push(row);
            }

            // Remove from highest index to lowest to avoid shifting issues
            rowIds
              .sort((a, b) => b - a)
              .forEach((row) => {
                removeBomItem(row);
                this.alter("remove_row", row);
              });
          }
        },
      },
      separator: ContextMenu.SEPARATOR,
      background_color: {
        name: "Background color",
        submenu: {
          items: colors.map((color) => ({
            key: `background_color:${color.key}`,
            name: `<span class="w-4 h-4 inline-block mr-2 border border-gray-300 ${
              !color.value
                ? "bg-gradient-to-br from-transparent via-transparent to-gray-300"
                : ""
            }" ${
              color.value
                ? `style="background-color: ${color.value};"`
                : 'style="background: repeating-linear-gradient(45deg, transparent, transparent 2px, #ccc 2px, #ccc 4px);"'
            }></span>${color.key}`,
            callback: function (key, hot) {
              if (hot) {
                const colorValue = colors.find(
                  (c) => `background_color:${c.key}` === key
                )?.value;
                if (colorValue !== undefined) {
                  changeCellBgColor(colorValue);
                }
              }
            },
          })),
        },
      },
      clear_background: {
        name: "Clear background",
        callback: function (_key, hot) {
          if (hot) {
            changeCellBgColor("");
          }
        },
      },
    },
  };

  return { ContextMenuConfig };
}

Store:

import { BOMItem, RowStyles } from "@/types";
import { create } from "zustand";

interface BomStore {
  bom: BOMItem[];
  styles: RowStyles;
  setBom: (bom: BOMItem[]) => void;
  setStyles: (styles: RowStyles) => void;
  addBomItem: (item: BOMItem, index?: number) => void;
  updateBomItem: (index: number, item: BOMItem) => void;
  removeBomItem: (index: number) => void;
  addStyle: (rowIndex: number, fieldName: string, style: any) => void;
}

export const useBomStore = create<BomStore>((set) => ({
  bom: [],
  styles: {},
  setBom: (bom) => set({ bom }),
  setStyles: (styles) => set({ styles }),

  addBomItem: (item: BOMItem, index?: number) =>
    set((state) => {
      if (index !== undefined) {
        const newBom = [...state.bom];
        newBom.splice(index, 0, item);

        // Update styles to account for the new item being inserted
        const newStyles: RowStyles = {};

        // Copy styles for rows before the insertion point
        for (let i = 0; i < index; i++) {
          if (state.styles[i]) {
            newStyles[i] = state.styles[i];
          }
        }

        // Add empty styles for the new item at the insertion point
        newStyles[index] = {};

        // Shift styles for rows after the insertion point
        Object.keys(state.styles).forEach((rowIndexStr) => {
          const rowIndex = parseInt(rowIndexStr);
          if (rowIndex >= index) {
            newStyles[rowIndex + 1] = state.styles[rowIndex];
          }
        });

        return { bom: newBom, styles: newStyles };
      }

      // If no index specified, add to the end with empty styles
      const newBom = [...state.bom, item];
      const newStyles = {
        ...state.styles,
        [state.bom.length]: {},
      };

      return { bom: newBom, styles: newStyles };
    }),

  updateBomItem: (index: number, item: BOMItem) =>
    set((state) => {
      const newBom = [...state.bom];
      newBom[index] = item;
      return { bom: newBom };
    }),

  removeBomItem: (index: number) =>
    set((state) => {
      const newBom = [...state.bom];
      newBom.splice(index, 1);

      // Update styles to account for the removed item
      const newStyles: RowStyles = {};

      // Copy styles for rows before the removal point
      for (let i = 0; i < index; i++) {
        if (state.styles[i]) {
          newStyles[i] = state.styles[i];
        }
      }

      // Shift styles for rows after the removal point
      Object.keys(state.styles).forEach((rowIndexStr) => {
        const rowIndex = parseInt(rowIndexStr);
        if (rowIndex > index) {
          newStyles[rowIndex - 1] = state.styles[rowIndex];
        }
      });

      return { bom: newBom, styles: newStyles };
    }),

  addStyle: (rowIndex: number, fieldName: string, style: any) =>
    set((state) => ({
      styles: {
        ...state.styles,
        [rowIndex]: {
          ...state.styles[rowIndex],
          [fieldName]: style,
        },
      },
    })),
}));

Hi @shenaldzone

Thank you for contacting us. Please share a minified code demo (in Stackblitz or jsFiddle) where the issue can be replicated with the minimal setup.