You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React TanStack Table输入框onBlur切换值异常问题求助

问题

参考TanStack Table的可编辑数据示例实现表格,包含“Current Value”可编辑列,基础功能正常,但使用Tab键导航表格时,单元格值会突然变化。尝试在onBlur事件中对比输入元素ID与行索引处理,未解决问题。代码如下:

import {
  RowData,
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";

import React, { useState, useEffect, useMemo } from "react";
import TestModeSelect from "./TestModeSelect";
import { Tooltip } from "flowbite-react";

declare module "@tanstack/react-table" {
  interface TableMeta<TData extends RowData> {
    updateData: (rowIndex: number, columnId: string, value: unknown) => void;
  }
}

const ConfigPanel: React.FC = () => {
  const [data, setData] = useState<MkDeviceCell[]>(() => []);
  const [testModeOptions, setTestModeOptions] = useState<MkDeviceTestMode[]>(
    []
  );
  const [quickModeOptions, setQuickModeOptions] = useState<MkDeviceQuickMode[]>(
    []
  );

  const readConfig = () => {
    getDeviceConfig()
      .then((result) => {
        setData(result.cells);
        setTestModeOptions(result.test_modes);
        setQuickModeOptions(result.quick_modes);
      })
      .catch((err) => {
        error(`Error occurred while trying to read device config: ${err}`);
      });
  };

  const writeConfig = () => {
    setDeviceConfig(data).then((success) => {
      if (success) {
        readConfig();
      }
    });
  };

  const factoryReset = async () => {
    let result = await ask(
      "This action will factory reset the device and cannot be reverted. Are you sure?",
      {
        title: "Tiny CC Tool",
        type: "warning",
      }
    );
    if (result) {
      if (await invoke("factory_reset", {})) {
        await readConfig();
      }
    }
  };

  const handleCellValueChange = (
    rowId: number,
    _columnId: string,
    value: string
  ) => {
    setData((old) => {
      return old.map((row) => {
        if (row.address === rowId) {
          row.current_value = parseInt(value);
        }
        return row;
      });
    });
  };

  const columns = useMemo<ColumnDef<MkDeviceCell>[]>(
    () => [
      {
        header: "Address",
        footer: (props) => props.column.id,
        accessorFn: (row) => row.address,
        id: "address",
      },
      {
        header: "Name",
        footer: (props) => props.column.id,
        accessorFn: (row) => [row.name, row.description],
        id: "name",
        cell: ({ getValue }) => {
          const [name, description] = getValue() as [string, string];
          return (
            <>
              <Tooltip
                content={toolTipData(description)}
                placement="right"
                style="light"
              >
                {`${name}`}
              </Tooltip>
            </>
          );
        },
      },
      {
        header: "Current Value",
        footer: (props) => props.column.id,
        accessorFn: (row) => [
          row.current_value,
          row.min_value,
          row.max_value,
          row.allowed_values,
          row.address,
        ],
        id: "current_value",
        cell: ({ getValue, row: { index }, column: { id }, table }) => {
          const [initialValue, minValue, maxValue, allowedValues, address] =
            getValue() as [number, number, number, number[], number];

          const [value, setValue] = useState(initialValue.toString());
          const [activeValue, setActiveValue] = useState("");

          // When the input is blurred, we'll call our table meta's updateData function
          const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
            const elemId = `ROW_${address}`;
            if (elemId !== e.target.id) {
              return;
            }
            const val = parseInt(value || "");
            if (allowedValues.includes(val)) {
              table.options.meta?.updateData(index, id, val);
            } else if (val >= minValue && val <= maxValue) {
              table.options.meta?.updateData(index, id, val);
            } else {
              // Handle out-of-range values
              console.error(
                `Value ${val} is outside the allowed range (${minValue} - ${maxValue})`
              );
              // You can add additional error handling or feedback to the user here
            }
          };

          const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
            setActiveValue(e.target.value);
          };

          const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
            console.log("On change");
            setValue(e.target.value);
          };

          // If the initialValue is changed external, sync it up with our state
          useEffect(() => {
            setValue(initialValue.toString());
          }, [initialValue]);

          return (
            <>
              <input
                id={`ROW_${address}`}
                key={`ROW_${address}`}
                value={value}
                onChange={handleOnChange}
                onBlur={onBlur}
                onFocus={onFocus}
              />
            </>
          );
        },
      },
    ],
    []
  );

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    meta: {
      updateData: (rowIndex, columnId, value) => {
        handleCellValueChange(rowIndex, columnId, value as string);
      },
    },
    debugTable: true,
  });

  function toolTipData(description: string) {
    const array = description.split("\n");

    return (
      <div className="text-left">
        {array.map((element, index) => (
          <div key={index}>
            {element}
            <br />
          </div>
        ))}
      </div>
    );
  }

  function showTable(data: Array<object>) {
    if (data.length > 0) {
      return (
        <table className="w-full text-center table border border-collapse ">
          <thead className="sticky top-0 bg-gray-50 text-center table-header-group border border-collapse  ">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody className="text-center ">
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className="border border-separate ">
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      );
    } else {
      return <div className="bg-gray-100 h-full"></div>;
    }
  }

  return (
    <div className="h-full flex flex-col border rounded-lg">
      <div className="overflow-y-scroll h-full">{showTable(data)}</div>
      <div className="p-2 bg-gray-50 border rounded-t-none rounded-lg sticky bottom-0 flex flex-row justify-between md:flex-wrap">
        <div>
          <button
            onClick={() => readConfig()}
            className=" bg-blue-700 text-white text-xs p-2 border border-blue-950 rounded-l-lg hover:bg-blue-900 md:text-xs lg:text-lg"
          >
            Read Config
          </button>
          <button
            onClick={() => writeConfig()}
            className=" bg-blue-700 text-white  border border-l-0 border-blue-950 text-xs p-2 hover:bg-blue-900 md:text-xs lg:text-lg"
          >
            Save Config
          </button>
          <button
            onClick={() => factoryReset()}
            className=" bg-blue-700 text-white text-xs border border-l-0 border-blue-950 rounded-r-lg p-2 hover:bg-blue-900 md:text-xs lg:text-lg"
          >
            Factory Reset
          </button>
        </div>
        <div className="float right md:mt-2">
          <TestModeSelect
            testModeOptions={testModeOptions}
            quickOptions={quickModeOptions}
          />
        </div>
      </div>
    </div>
  );
};

export default ConfigPanel;
解决方案

核心问题分析

  1. 行匹配逻辑错误:updateData传入的是行索引(row.index),但handleCellValueChange中用row.address === rowId匹配行——行索引是表格内的位置序号,address是设备的唯一标识,两者语义完全不同,导致无法正确更新目标行数据,进而引发渲染异常。
  2. 单元格状态同步问题:每个单元格独立维护value状态,且通过useEffect无条件监听initialValue(来自父组件data)。当其他单元格更新导致data变化时,所有单元格会重新渲染,useEffect会强制将value重置为initialValue,导致正在编辑的单元格输入内容丢失。

修复步骤

1. 修正行匹配逻辑

在单元格的onBlur事件中,传入设备address而非行索引到updateData,确保handleCellValueChange能正确匹配目标行:

// 在current_value列的cell组件中修改onBlur内的调用
table.options.meta?.updateData(address, id, val);

同时优化handleCellValueChange的参数命名,避免混淆:

const handleCellValueChange = (
  deviceAddress: number,
  _columnId: string,
  value: string
) => {
  setData((old) => {
    return old.map((row) => {
      if (row.address === deviceAddress) {
        row.current_value = parseInt(value);
      }
      return row;
    });
  });
};

2. 优化单元格状态管理

添加isEditing状态,仅在单元格未处于编辑状态时同步initialValue到value,避免编辑中被强制重置:

// 在current_value列的cell组件内修改
const [value, setValue] = useState(initialValue.toString());
const [isEditing, setIsEditing] = useState(false);

const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
  setIsEditing(false);
  // ... 原有逻辑
};

const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
  setIsEditing(true);
  setActiveValue(e.target.value);
};

// 仅在非编辑状态下同步initialValue
useEffect(() => {
  if (!isEditing) {
    setValue(initialValue.toString());
  }
}, [initialValue, isEditing]);

完整修复后的核心代码片段

// 修正后的handleCellValueChange
const handleCellValueChange = (
  deviceAddress: number,
  _columnId: string,
  value: string
) => {
  setData((old) => {
    return old.map((row) => {
      if (row.address === deviceAddress) {
        row.current_value = parseInt(value);
      }
      return row;
    });
  });
};

// 修正后的current_value列cell组件
cell: ({ getValue, column: { id }, table }) => {
  const [initialValue, minValue, maxValue, allowedValues, address] =
    getValue() as [number, number, number, number[], number];

  const [value, setValue] = useState(initialValue.toString());
  const [isEditing, setIsEditing] = useState(false);
  const [activeValue, setActiveValue] = useState("");

  const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    setIsEditing(false);
    const elemId = `ROW_${address}`;
    if (elemId !== e.target.id) {
      return;
    }
    const val = parseInt(value || "");
    if (allowedValues.includes(val)) {
      table.options.meta?.updateData(address, id, val);
    } else if (val >= minValue && val <= maxValue) {
      table.options.meta?.updateData(address, id, val);
    } else {
      console.error(
        `Value ${val} is outside the allowed range (${minValue} - ${maxValue})`
      );
    }
  };

  const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
    setIsEditing(true);
    setActiveValue(e.target.value);
  };

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  useEffect(() => {
    if (!isEditing) {
      setValue(initialValue.toString());
    }
  }, [initialValue, isEditing]);

  return (
    <input
      id={`ROW_${address}`}
      key={`ROW_${address}`}
      value={value}
      onChange={handleOnChange}
      onBlur={onBlur}
      onFocus={onFocus}
    />
  );
},

内容的提问来源于stack exchange,提问作者technusm1

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 11:05:54