Editable Table in React: Click-to-Edit Cell with Validation
Build a click-to-edit React table with inline validation, keyboard nav, and Tailwind styling — no heavy libraries needed, just clean component logic.
Why Roll Your Own Editable Table?
Honestly, most editable table libraries are overkill for what you actually need. AG Grid, Handsontable, React Table with TanStack — they're excellent tools, but they come with thousands of lines of config, licensing headaches, and bundle sizes that'll make your Lighthouse score cry. If you need spreadsheet-level power, sure, reach for them. But if you need a simple click-to-edit table with validation? You don't.
What we're building here is a self-contained editable table component in React with TypeScript and Tailwind v4.0.2. Cells switch to an input on click, validate on blur or Enter, and revert on Escape. It weighs almost nothing, it's yours to own, and it'll slot into any design system you're already running.
This pattern comes up constantly — pricing tables users can adjust, admin dashboards where staff edit records inline, kanban-style data grids. Once you understand the state machine behind it, you can adapt this to any column type.
Modeling the Cell State Machine
The trickiest part isn't the UI — it's tracking which cell is currently being edited without letting that state bleed everywhere. We need three things: a way to identify the active cell (row index + column key), a temporary value held in the input, and a way to commit or discard that value.
The cleanest approach is a single editingCell state object holding { row: number; col: string } | null. When it's null, every cell renders as display text. When it matches a cell's coordinates, that one cell renders an <input>. This avoids per-cell state and keeps re-renders predictable.
Why not just store isEditing on each row object? Because then you're mutating your data model just to track UI state. Keep them separate. Your data stays immutable until the user actually confirms an edit — that matters a lot once you add async saves or optimistic updates.
Building the Editable Table Component
Here's the full component. It handles click-to-edit, Enter to confirm, Escape to cancel, Tab to move to the next cell, and basic required-field validation. Tailwind classes use the ring focus utilities that shipped cleanly in v4.0.2.
// EditableTable.tsx
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
type Row = Record<string, string>;
interface Column {
key: string;
label: string;
validate?: (value: string) => string | null; // returns error message or null
}
interface EditableTableProps {
columns: Column[];
initialData: Row[];
onSave?: (data: Row[]) => void;
}
interface ActiveCell {
row: number;
col: string;
}
export function EditableTable({ columns, initialData, onSave }: EditableTableProps) {
const [data, setData] = useState<Row[]>(initialData);
const [editingCell, setEditingCell] = useState<ActiveCell | null>(null);
const [draftValue, setDraftValue] = useState('');
const [cellError, setCellError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Focus the input whenever editingCell changes
useEffect(() => {
if (editingCell) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editingCell]);
const startEdit = (rowIdx: number, colKey: string) => {
setEditingCell({ row: rowIdx, col: colKey });
setDraftValue(data[rowIdx][colKey] ?? '');
setCellError(null);
};
const commitEdit = () => {
if (!editingCell) return;
const col = columns.find(c => c.key === editingCell.col);
const error = col?.validate?.(draftValue) ?? null;
if (error) {
setCellError(error);
return; // keep the cell open
}
setData(prev =>
prev.map((row, i) =>
i === editingCell.row ? { ...row, [editingCell.col]: draftValue } : row
)
);
setEditingCell(null);
setCellError(null);
};
const cancelEdit = () => {
setEditingCell(null);
setDraftValue('');
setCellError(null);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { e.preventDefault(); commitEdit(); }
if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
if (e.key === 'Tab') {
e.preventDefault();
commitEdit();
// move to next column
if (!editingCell) return;
const colIdx = columns.findIndex(c => c.key === editingCell.col);
const nextCol = columns[colIdx + 1];
if (nextCol) startEdit(editingCell.row, nextCol.key);
}
};
const isEditing = (rowIdx: number, colKey: string) =>
editingCell?.row === rowIdx && editingCell?.col === colKey;
return (
<div className="overflow-x-auto rounded-xl border border-white/10">
<table className="w-full text-sm text-left">
<thead className="bg-white/5 text-xs uppercase tracking-wider text-gray-400">
<tr>
{columns.map(col => (
<th key={col.key} className="px-4 py-3 font-medium">
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{data.map((row, rowIdx) => (
<tr key={rowIdx} className="group hover:bg-white/5 transition-colors">
{columns.map(col => (
<td
key={col.key}
className="px-4 py-2 relative"
onClick={() => !isEditing(rowIdx, col.key) && startEdit(rowIdx, col.key)}
>
{isEditing(rowIdx, col.key) ? (
<div className="flex flex-col gap-1">
<input
ref={inputRef}
value={draftValue}
onChange={e => { setDraftValue(e.target.value); setCellError(null); }}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className={[
'w-full bg-white/10 rounded-md px-2 py-1',
'ring-2 outline-none transition-all',
cellError
? 'ring-red-500/70 text-red-300'
: 'ring-violet-500/70 text-white',
].join(' ')}
/>
{cellError && (
<span className="text-xs text-red-400">{cellError}</span>
)}
</div>
) : (
<span className="block cursor-pointer rounded px-2 py-1 text-gray-200 group-hover:text-white">
{row[col.key] || <span className="text-gray-500 italic">empty</span>}
</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}A few deliberate choices here. The onBlur on the input calls commitEdit — so clicking outside any cell saves the value (or shows the error and keeps focus via the effect). The useEffect on editingCell ensures the browser always focuses the new input even when Tab moves across columns. These two details alone prevent the most common UX bugs in editable tables.
Adding Column-Level Validation
Validation in this model is a simple function on each column definition: it receives the current string value and returns either null (valid) or an error message string. That's it. No schema libraries required, though you can absolutely plug in Zod if you want.
Here's a realistic usage example with a few column types and different validation rules:
const columns: Column[] = [
{
key: 'name',
label: 'Name',
validate: v => v.trim().length < 2 ? 'Name must be at least 2 characters' : null,
},
{
key: 'email',
label: 'Email',
validate: v => {
if (!v.includes('@')) return 'Must be a valid email address';
return null;
},
},
{
key: 'price',
label: 'Price ($)',
validate: v => {
const n = parseFloat(v);
if (isNaN(n) || n < 0) return 'Price must be a positive number';
return null;
},
},
{ key: 'notes', label: 'Notes' }, // no validation = always valid
];
const rows = [
{ name: 'Starter Plan', email: 'starter@acme.com', price: '9.00', notes: '' },
{ name: 'Pro Plan', email: 'pro@acme.com', price: '29.00', notes: 'Most popular' },
];
// Usage:
<EditableTable columns={columns} initialData={rows} />Notice there's no global form state here. Each cell validates independently when the user tries to commit. This is intentional — in a large table, running all validations on every keystroke would be wasteful and annoying. Validate on commit, not on change.
Keyboard Navigation and Accessibility
Enter to save, Escape to cancel, Tab to move right — that's the minimum keyboard contract users expect from any editable data grid. We've already wired those up. But there's more to cover if real accessibility matters to you.
Each display cell should have role="gridcell" and tabIndex={0} with an onKeyDown handler that starts editing on Enter or Space. Screen readers need to know a cell is interactive before a user actually clicks it. Add aria-label={Edit ${col.label}} on the clickable span and aria-invalid={!!cellError} on the input. These aren't hard to add, they just take five minutes and they make a real difference.
Does your table need to handle hundreds of rows? That's a different problem — virtual scrolling, not editable cells. For tables under ~500 rows rendering in the DOM at once, this approach is fine. For larger datasets, look at pairing this editing logic with TanStack Virtual for windowed rendering. The editing state machine stays identical; only the rendering layer changes.
Persisting Edits: Local State, Context, and Async Saves
Right now the component owns its data. That's great for prototyping but you'll need to lift that state or connect it to a server. The onSave prop we defined is your hook — call it after commitEdit succeeds and let the parent decide whether to PATCH an API, update a Zustand store, or write to a cache.
For optimistic UI, flip the approach slightly: call onSave immediately with the new value, then roll back on error. Store a savedData ref alongside the data state, and if the async call fails, restore data from savedData and show a toast. That 200ms optimistic update makes inline editing feel instant even over a slow connection.
If you're working across multiple components or pages, pair this with a theme toggle to ensure the table respects light/dark mode automatically — especially important if you're embedding tables inside dashboards that mix light and dark sections. The Tailwind dark: prefix keeps that logic in your markup rather than in JS.
Styling with Tailwind and Empire UI
The component above uses a dark glass-ish aesthetic — bg-white/10, border-white/10, divide-white/5 — which reads cleanly over dark backgrounds. If you're on a light theme, swap those for solid grays: bg-gray-50, border-gray-200, divide-gray-100. Tailwind v4.0.2's ring utilities handle the focus indicator with no extra CSS.
You can pair this table with any Empire UI component. Drop it inside a bento grid layout for a dashboard that shows charts and editable data side-by-side. Or wrap the whole thing in a modal triggered by an animated button for a "quick edit" pattern. The table itself is style-agnostic — it just needs its container to give it a background.
One thing worth calling out: the hover state group-hover:bg-white/5 on rows gives users a visual cue that cells are clickable before they click. Don't skip it. Without it, the table looks static and users won't discover the editing affordance. A 150ms transition-colors keeps it from feeling abrupt.
Common Gotchas and How to Avoid Them
The onBlur + commitEdit pairing bites a lot of people. When the user clicks the save button (if you add one), the input fires onBlur before the button's onClick — so the commit happens twice. Prevent that by calling e.preventDefault() in the button's onMouseDown to stop focus from leaving the input. Then handle the save in onClick as normal.
Another subtle issue: useEffect with inputRef.current?.focus() runs *after* paint. On slow devices this is usually fine, but if you see a one-frame flash where the input is visible but not focused, call inputRef.current?.focus() synchronously inside startEdit instead. The effect is a safety net, not the primary path.
Finally, be careful with column keys that contain dots or brackets — row['user.name'] won't work as a nested path. If your data has nested structure, flatten it before passing it to the table, or write a tiny getNestedValue(row, key) helper. Keeping the table component itself dumb about data shape means you can reuse it anywhere — including as a base for more specialised editors like a cards stack that lets users edit card content inline.
FAQ
In the handleKeyDown handler, call commitEdit() on Tab and only move to the next cell if commitEdit returns true. To do that, change commitEdit to return a boolean — return true after a successful save and return false if there's a validation error. Then gate the startEdit call on that return value.
Yes. Extend the Column interface with a type field ('text' | 'select' | 'date' | 'boolean') and an optional options array for selects. Then in the editing branch of each cell, render the appropriate input element based on col.type. The editingCell, draftValue, and commitEdit logic stays the same regardless of input type.
Track a dirtyRows set in state — a Set<number> of row indices that have unsaved changes. Update it when any cell in that row is committed. Render a save button at the end of each row when dirtyRows.has(rowIdx) is true. On click, call onSave with that row's data and remove it from dirtyRows. You'll also want to suppress the onBlur commit and only save on explicit confirmation.
The component itself is fully compatible with React 18 and 19. If your onSave triggers a large state update (like refetching a full dataset), wrap it in startTransition to mark it as non-urgent and keep the UI responsive during the update. The local draftValue and editingCell state should stay outside any transition since they drive immediate input feedback.
Add an editable function to your column config (or a row-level locked flag) and check it before calling startEdit. In the cell's onClick handler: if (!isRowEditable(row)) return;. Style locked cells with cursor-default opacity-60 so users get a clear signal they can't edit them.
Keep a history stack alongside your data state — an array of previous Row[] snapshots. Every successful commitEdit pushes the current data onto the stack before applying the change. Expose an undo function that pops from the stack and restores data. Limit stack depth to something reasonable like 50 entries to avoid memory issues on long sessions.