Skip to main content

Table Inline Inputs

From a user perspective, table inline inputs are a great way to edit tabular data without switching context.
From a developer perspective however, they are a bit of a pain to use.

Here are some quirks and tips that we've found that makes using table inline inputs a bit less painful.

1. Use a separate component for the inline input#

We can define an edit component for a column of the eva-suite-ui Table with the editComponent prop. This is a function that has two props: cell (which holds the cell data) and onFieldUpdate (which updates the cell data), and it returns a component that will be rendered inside the cell.

The problem is, that this function is not treated as a React FunctionComponent, which means that if we define it inline, we cannot use hooks (useEffect, useState, etc.) inside it.

We can, however, use a separate component for the inline input. This is a good practice to follow, because it makes the code more readable and the components less cluttered.

You can extend the following type as props for the component:

import { ITableCell } from '@springtree/eva-suite-ui';
export interface ITableInlineControlProps {  cell: ITableCell<any>;  onFieldUpdate: (state: any) => void;}

Example component for inline text input

TableInlineTextInput.tsx
import { useCallback, useEffect, useState } from 'react';import { Input, ITableCell } from '@springtree/eva-suite-ui';
interface ITableInlineTextInputProps {  label?: string;  required?: boolean;  helperText?: string;  cell: ITableCell<any>;  onFieldUpdate: (state: any) => void;}
/** This component is used to render an eva-suite-ui TextInput input for a table cell.* We need a separate component because inline we cannot use React Hooks.* ---* @param cell The cell to render the input for. (provided by the TableColumn editComponent prop)* @param onFieldUpdate A callback to update the cell value. (provided by the TableColumn editComponent prop)* @param label The label to use for the input.* @param helperText The helper text to use for the input (only displayed when the input is invalid).* Use it with the `required` prop to display a required error.* @param required Whether the input is required (and whether the value gets validated).*/const TableInlineTextInput = ({  cell,  onFieldUpdate,  label,  helperText,  required,}: ITableInlineTextInputProps) => {  const [value, setValue] = useState<string | undefined>(cell.value);  const [shouldValidate, setShouldValidate] = useState<boolean>(false);
  useEffect(() => {    setValue(cell.value);  }, [cell.value]);
  const handleChange = useCallback(    (newValue: string) => {      setValue(newValue);      onFieldUpdate(newValue);    },    [onFieldUpdate],  );
  return (    <Input      small      label={label}      required={required}      value={value ?? ''}      onChange={(event) => handleChange(event.target.value)}      onBlur={required ? () => setShouldValidate(true) : undefined}      error={(!value || value === '') && shouldValidate && required}      helperText={        (!value || value === '') && shouldValidate && required          ? helperText          : undefined      }    />  );};
TableInlineTextInput.defaultProps = {  required: false,  label: undefined,  helperText: undefined,};
export default TableInlineTextInput;

2. Use a local state to keep track of the inline input value#

During development we've found that cell.value and onFieldUpdate are not working as we'd expect - sometimes the values are late, or sometimes they are not updated at all. Hence, we need to keep track of the value locally.

We also need to do 2-way binding between the local state and the cell value. This means that we have to update the cell when the local state changes, and we need to update the local state when the cell value changes.

We've found that if we don't do this, then the user will have 'leftover' values in a cell, which wasn't even modified yet.

If we were to use two useEffect hooks (cell.value -> localState, localState -> cell.value), the component would just get in a render loop. To prevent this, we've found a good way to bind the two states.

The gist is:

import { useCallback, useEffect, useState } from 'react';import { Input, ITableCell } from '@springtree/eva-suite-ui';
export interface ITableInlineInputControlProps {  cell: ITableCell<any>;  onFieldUpdate: (state: any) => void;}
const TableInlineInput = ({ cell, onFieldUpdate }: ITableInlineInputControlProps) => {  // initialize the local state with the cell value  const [value, setValue] = useState<string | undefined>(cell.value);
  // in a `useEffect` hook, update the local state when the cell value changes  useEffect(() => {    setValue(cell.value);  }, [cell.value]);
  // when the value changes, update both the cell value and the local state  const handleChange = useCallback(    (newValue: string) => {      setValue(newValue);      onFieldUpdate(newValue);    },    [onFieldUpdate],  );
  return (    <Input      small      value={value ?? ''}      onChange={(event) => handleChange(event.target.value)}    />  );};
export default TableInlineInput;

3. Discard unsaved rows when adding a new row#

Here's the problem that we've encountered:

This can be solved by discarding all unedited rows upon creating a new row.

// ...
  const tableDataFromService = useTableDataFromService(); // TableDataRow[];
  const [tableData, setTableData] = useState<Partial<TableDataRow>[]>([]);
  useEffect(() => {    if (tableDataFromService) {      setTableData(tableDataFromService);    }  }, [tableDataFromService]);
  const handleCreate = useCallback(() => {    const newRow = {} as TableDataRow;    // Instead of this:    // setTableData((state) => [newRow, ...state]);    // Use this:    setTableData([newRow, ...tableDataFromService]);  }, [tableDataFromService]);
// ...