nahuel.luca()

Create Table with Dynamic Inputs

In this blog we will see how to create a table containing dynamic inputs, we will use TanStack Table together with zod for validations.

Getting started

After installing the necessary dependencies we will create a data.ts file that will be a mock of our data.

// src/components/table/data.ts
import { Product } from "./types";

export const data: Product[] = [
  {
    id: 1,
    title: "Essence Mascara",
    category: "beauty",
    price: 9.99,
    discountPercentage: 7.17,
    rating: 4.94,
    stock: 5,
  },
  {
    id: 2,
    title: "Eyeshadow Palette",
    category: "beauty",
    price: 19.99,
    discountPercentage: 5.5,
    rating: 3.28,
    stock: 44,
  },
  {
    id: 3,
    title: "Powder Canister",
    category: "beauty",
    price: 14.99,
    discountPercentage: 18.14,
    rating: 3.82,
    stock: 59,
  },
  {
    id: 4,
    title: "Red Lipstick",
    category: "beauty",
    price: 12.99,
    discountPercentage: 19.03,
    rating: 2.51,
    stock: 68,
  },
];

We will also create a types.ts file and export our Products type.

// src/components/table/types.ts
export type Product = {
  id: number;
  title: string;
  category: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
};
};

Creating the Table

To create the columns of our table we will use createColumnHelper which will provide us with a helper to generate the column definitions. We are going to define the headers and the data type of each column.

const columnHelper = createColumnHelper<Product>();
const columns = [
  columnHelper.accessor("id", { header: "id", meta: { type: "number" } }),
  columnHelper.accessor("title", { header: "Title", meta: { type: "text" } }),
  columnHelper.accessor("category", {
    header: "category",
    meta: { type: "text" },
  }),
  columnHelper.accessor("price", {
    header: "Price",
    meta: { type: "number" },
  }),
  columnHelper.accessor("discountPercentage", {
    header: "Discount Percentage",
    meta: { type: "number" },
  }),
  columnHelper.accessor("rating", {
    header: "Rating",
    meta: { type: "number" },
  }),
];

Then, we will use useReactTable() to create our table, we will pass it our columns and our data.

import { data as defaultData } from "./data";

export default function TableComponent() {
  const [data, setData] = useState(() => [...defaultData]);
  // columns code

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    // render table
  )
}

💡getCoreRowModel: This required option is a factory for a function that computes and returns the core row model for the table.

We just need to render our table and this is how it would look like:

export default function TableComponent() {
  const [data, setData] = useState(() => [...defaultData]);
  const columnHelper = createColumnHelper<Product>();
  const columns = [
    columnHelper.accessor("id", { header: "id", meta: { type: "number" } }),
    columnHelper.accessor("title", { header: "Title", meta: { type: "text" } }),
    columnHelper.accessor("category", {
      header: "category",
      meta: { type: "text" },
    }),
    columnHelper.accessor("price", {
      header: "Price",
      meta: { type: "number" },
    }),
    columnHelper.accessor("discountPercentage", {
      header: "Discount Percentage",
      meta: { type: "number" },
    }),
    columnHelper.accessor("rating", {
      header: "Rating",
      meta: { type: "number" },
    }),
  ];

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div>
      <h1 className="mb-10">Table</h1>
      <table className="text-start max-w-[200px] border-2 border-red-200">
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th className="text-start bg-red-400 py-2 px-8" key={header.id}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                </th>
              ))}
            </tr>
          ))}
        </thead>

        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id} className="">
              {row.getVisibleCells().map((cell) => (
                <td
                  key={cell.id}
                  className="px-8 py-2 border-b-2 border-red-200 "
                >
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

📌flexRender: sometimes we return a jsx, a string or a function that return one of this both, flexRender will handle it for us.

Adding Inputs to the table

We will create the file cell-input.tsx, then we will create a component that will receive by props getValue, row, column, table. What will we do with these props? Well, with getValue we will get the initial value, row we will use it to access the row id and its index, column we will use it to access the ColumnMeta and table to access the TableMeta.

What are ColumnMeta and TableMeta?

Well ColumnMeta is the meta data associated to the column we can access using column.columnDef.meta. Also to our table we can pass any object to options.meta which we will be able to access from table.options.meta.

export default function CellInput({
  getValue,
  row,
  column,
  table,
}: {
  getValue: () => string | number;
  row: Row<Product>;
  column: Column<Product>;
  table: Table<Product>;
}) {
  const initialValue = getValue();
  const columnMeta = column.columnDef.meta;
  const tableMeta = table.options.meta;
  const [value, setValue] = useState(initialValue);

  useEffect(() => {
    setValue(initialValue);
  }, [initialValue]);

  const onBlur = () => {
    tableMeta?.updateData(row.index, column.id, value);
  };

  const onSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
    setValue(e.target.value);
    tableMeta?.updateData(row.index, column.id, e.target.value);
  };

  if (tableMeta?.editedRows[Number(row.id)]) {
    return columnMeta?.type === "select" ? (
      <select
        className="max-w-[100px] invalid:border-red-500"
        onChange={onSelectChange}
        value={initialValue}
        required={columnMeta?.required}
      >
        {columnMeta?.options?.map((option: Option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    ) : (
      <input
        className="max-w-[100px] invalid:border-red-500"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onBlur={onBlur}
        type={column.columnDef.meta?.type || "text"}
        required={columnMeta?.required}
      />
    );
  }
  return (
    <span className="w-full flex min-w-[100px] text-ellipsis line-clamp-2 max-h-[100px]">
      {value}
    </span>
  );
}

What we are doing is to see if we are in a row that is being edited with tableMeta?.editedRows[Number(row.id)] if so, we access the columnMeta?.type property to know if it is a select input or not. And then we render the input and continue using our columnMeta to get information about the input such as whether it is required, the type and the options if it has. If the row is not being edited, we simply return an <span> with the value.

Extending interfaces

To avoid typing problems we will extend the existing ColumnMeta and TableMeta interfaces.

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

  interface ColumnMeta<TData extends RowData, TValue> {
    type?: "text" | "number" | "select";
    required?: boolean;
    options: { label: string; value: string }[];
  }
}

Adding our Input Cell

Now we will add to our columns the required meta data and the cell type which will be CellInput.

const columns = [
  columnHelper.accessor("id", {
    header: "id",
    meta: { type: "number" },
  }),
  columnHelper.accessor("title", {
    header: "Title",
    meta: { type: "text", required: true },
    cell: CellInput,
  }),
  columnHelper.accessor("category", {
    header: "category",
    meta: {
      type: "select",
      required: true,
      options: [
        { value: "beauty", label: "beauty" },
        { value: "fragrances", label: "fragrances" },
        { value: "groceries", label: "groceries" },
        { value: "kitchen-accessories", label: "kitchen-accessories" },
      ],
    },
    cell: CellInput,
  }),
  columnHelper.accessor("price", {
    header: "Price",
    meta: { type: "number", required: true },
    cell: CellInput,
  }),
  columnHelper.accessor("discountPercentage", {
    header: "Discount Percentage",
    meta: { type: "number", required: true },
    cell: CellInput,
  }),
  columnHelper.accessor("rating", {
    header: "Rating",
    meta: { type: "number", required: true },
    cell: CellInput,
  }),
];

The next thing is to create CellEdit which we will use to edit or delete a row.

export default function CellEdit({
  row,
  table,
}: {
  row: Row<Product>;
  table: Table<Product>;
}) {
  const meta = table.options.meta;
  const setEditedRows = (e: MouseEvent<HTMLButtonElement>) => {
    const elName = e.currentTarget.name;
    meta?.setEditedRows((old: []) => ({
      ...old,
      [row.id]: !old[row.id],
    }));
    if (elName !== "edit") {
      meta?.revertData(row.index, e.currentTarget.name === "cancel");
    }
  };

  const removeRow = () => {
    meta?.removeRow(row.index);
  };

  return (
    <div className="edit-cell-container">
      {meta?.editedRows[Number(row.id)] ? (
        <div className="edit-cell-action">
          <button onClick={setEditedRows} name="cancel">
            ⚊
          </button>

          <button onClick={setEditedRows} name="done">
            ✔
          </button>
        </div>
      ) : (
        <div className="edit-cell-action">
          <button onClick={setEditedRows} name="edit">
            ✐
          </button>

          <button onClick={removeRow} name="remove">
            X
          </button>
        </div>
      )}
    </div>
  );
}

For this to work we have to add to our table setEditedRows, editedRows, revertData and removeRow to the options.meta and also extend our interface so that there are no type errors.

interface TableMeta<TData extends RowData> {
  updateData: (
    rowIndex: number,
    columnId: string,
    value: string | number
  ) => void;
  editedRows: Record<number, boolean>;
  setEditedRows: React.Dispatch<React.SetStateAction<object>>;
  revertData: (rowIndex: number, isCancel: boolean) => void;
  removeRow: (rowIndex: number) => void;
}

We will add the originalData and editedRows states to handle the editing of our rows, next to this in columns we will add CellEdit.

const [data, setData] = useState(() => [...defaultData]);
const [originalData, setOriginalData] = useState(() => [...defaultData]);
const [editedRows, setEditedRows] = useState({});

const columns = [
  // other columns
  columnHelper.display({
   id: "edit",
   cell: CellEdit,
 }),
];

And in our table we will add the necessary options.meta.

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  meta: {
    editedRows,
    setEditedRows,
    updateData: (rowIndex, columnId, value) => {
      setData((old) => {
        const updated = [...old];
        updated[rowIndex] = { ...updated[rowIndex], [columnId]: value };
        return updated;
      });
    },
    revertData: (rowIndex: number, revert: boolean) => {
      if (revert) {
        setData((old) =>
          old.map((row, index) =>
            index === rowIndex ? originalData[rowIndex] : row
          )
        );
      } else {
        setOriginalData((old) =>
          old.map((row, index) => (index === rowIndex ? data[rowIndex] : row))
        );
      }
    },
    removeRow: (rowIndex: number) => {
      const setFilterFunc = (old: Product[]) =>
        old.filter((_row: Product, index: number) => index !== rowIndex);
      setData(setFilterFunc);
      setOriginalData(setFilterFunc);
    },
  },
});

Add rows

To add rows we will simply add an addRow function in the options.meta of the table and add FooterCell at the end of the table.

addRow: () => {
  const newRow: Product = {
    id: Math.floor(Math.random() * 10000),
    title: "",
    category: "",
    price: 0,
    discountPercentage: 0,
    rating: 0,
    stock: 0,
};

  const setFunc = (old: Product[]) => [...old, newRow];
  setData(setFunc);
  setOriginalData(setFunc);
}
export const CellFooter = ({ table }: { table: Table<Product> }) => {
  const meta = table.options.meta;
  return (
    <div className="footer-buttons">
      <button className="add-button" onClick={meta?.addRow}>
        Add New +
      </button>
    </div>
  );
};
// rest of table
<tfoot>
  <tr>
    <th colSpan={table.getCenterLeafColumns().length} align="right">
      <CellFooter table={table} />
    </th>
  </tr>
</tfoot>

Perfect, we can now add rows.

Adding Validations

For the validations we will use zod, the first thing we will do is to type our validation that will go in the meta data of our colunns.

type SafeParseReturnType<T> = { success: true; data: T; } | { success: false; error: z.ZodError; };

interface ColumnMeta<TData extends RowData, TValue> {
  type?: "text" | "number" | "select";
  required?: boolean;
  options?: { label: string; value: string }[];
  validation?: (value: TValue) => SafeParseReturnType<TValue>;
}

Then we will add the validations we want in the meta of our columns, for example in Title:

meta: {
  type: "text",
  required: true,
  validation: (value: string) =>
    z
      .string()
      .min(4, { message: "Title must be at least 4 characters" })
      .safeParse(value),
},

And finally we will modify our CellInput to show do the validation and show the error:

export default function CellInput({
  getValue,
  row,
  column,
  table,
}: {
  getValue: () => string | number;
  row: Row<Product>;
  column: Column<Product>;
  table: Table<Product>;
}) {
  // rest of states
  const [validationMessage, setValidationMessage] = useState("");

  useEffect(() => {
    setValue(initialValue);
  }, [initialValue]);

  const onBlur = (e: ChangeEvent<HTMLInputElement>) => {
    displayValidationMessage(e);
    tableMeta?.updateData(row.index, column.id, value);
  };

  const onSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
    displayValidationMessage(e);
    setValue(e.target.value);
    tableMeta?.updateData(row.index, column.id, e.target.value);
  };

  const displayValidationMessage = <
    T extends HTMLInputElement | HTMLSelectElement
  >(
    e: ChangeEvent<T>
  ) => {
    if (columnMeta?.validation) {
      const isValid = columnMeta.validation(e.target.value);
      if (isValid.success) {
        e.target.setCustomValidity("");
        setValidationMessage("");
      } else {
        e.target.setCustomValidity(String(isValid.error.errors[0].message));
        setValidationMessage(String(isValid.error.errors[0].message));
      }
    } else if (e.target.validity.valid) {
      setValidationMessage("");
    } else {
      setValidationMessage(e.target.validationMessage);
    }
  };

  if (tableMeta?.editedRows[Number(row.id)]) {
    return columnMeta?.type === "select" ? (
      <div>
        <select
          className="max-w-[100px] invalid:border-red-500"
          onChange={onSelectChange}
          value={initialValue}
          required={columnMeta?.required}
        >
          {columnMeta?.options?.map((option: Option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
        <span className="text-red-500">{validationMessage}</span>
      </div>
    ) : (
      <div>
        <input
          className="max-w-[100px] invalid:border-red-500"
          value={value}
          onChange={(e) => setValue(e.target.value)}
          onBlur={onBlur}
          type={columnMeta?.type || "text"}
          required={columnMeta?.required}
        />
        <span className="text-red-500">{validationMessage}</span>
      </div>
    );
  }

  return (
    <span className="w-full flex min-w-[100px] text-ellipsis line-clamp-2 max-h-[100px]">
      {value}
    </span>
  );
}

Here what we do is simply use columnMeta.validation(e.target.value) to check if the value is valid or not, if it is not valid we set the message in validationMessage which we will then display below our inputs.

This is all

I hope this guide has been helpful. You can find the code here and see the deployed version here.