Comparing “Repeating Rows” Field Type vs. Nested Fields: Pros and Cons

How to Implement a “Repeating Rows” Field Type: Step-by-Step GuideA “Repeating Rows” field type lets users add multiple, similar items within a single form — for example, line items on an invoice, multiple addresses, or attendee lists. Implementing this component well improves UX, simplifies data handling, and keeps forms flexible. This guide walks through design, data structure, UI, validation, persistence, and performance considerations with practical code examples.


1. Define requirements and UX patterns

Before coding, clarify how the repeating rows should behave:

  • Maximum and minimum item counts (e.g., min 1, max 20).
  • Whether rows are ordered and reorderable.
  • Support for nested fields inside each row (text, selects, datepickers, file inputs).
  • Editing, adding, and deleting flows (instant delete vs soft-delete).
  • Validation scope (per-row and cross-row rules).
  • How rows are persisted (single JSON column, relational table).
  • Accessibility and keyboard interactions.

Decisions here determine data model, API design, and UI complexity.


2. Data model and storage patterns

Choose a storage strategy based on app scale and query needs:

  • JSON column (NoSQL-style or PostgreSQL JSONB): simple for arbitrary fields, easy to serialize/deserialze, good for forms where rows are always retrieved together.
  • Relational table (one-to-many): each row is a separate record with a foreign key to the parent; better for querying/filtering rows independently and applying DB-level constraints.
  • Hybrid: structured columns for frequent queries and a JSONB column for flexible or rarely queried data.

Example relational schema (SQL):

CREATE TABLE invoices (   id SERIAL PRIMARY KEY,   customer_id INTEGER NOT NULL,   created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE invoice_line_items (   id SERIAL PRIMARY KEY,   invoice_id INTEGER REFERENCES invoices(id) ON DELETE CASCADE,   description TEXT,   quantity INTEGER NOT NULL DEFAULT 1,   unit_price NUMERIC(12,2),   position INTEGER, -- for ordering   created_at TIMESTAMP DEFAULT NOW() ); 

Example JSONB storage inside a parent row:

CREATE TABLE forms (   id SERIAL PRIMARY KEY,   data JSONB,   created_at TIMESTAMP DEFAULT NOW() ); -- data might contain { "attendees": [{ "name": "...", "email": "..." }, ...] } 

3. Front-end structure and state management

The repeating rows UI usually maps to an array in state. Consider these tasks:

  • Represent rows as an array of objects with stable IDs (temporary client-side IDs until saved).
  • Provide Add, Remove, and optional Reorder actions.
  • Track validation errors per-row and aggregate form validity.
  • Minimize re-renders by isolating row components and using keys.

Example using React (functional components + hooks):

import React, { useState } from "react"; import { v4 as uuidv4 } from "uuid"; function RepeatingRowsForm({ initialRows = [] }) {   const [rows, setRows] = useState(     initialRows.map(r => ({ ...r, _tempId: uuidv4() }))   );   function addRow() {     setRows(prev => [...prev, { _tempId: uuidv4(), description: "", quantity: 1 }]);   }   function updateRow(id, patch) {     setRows(prev => prev.map(r => (r._tempId === id ? { ...r, ...patch } : r)));   }   function removeRow(id) {     setRows(prev => prev.filter(r => r._tempId !== id));   }   return (     <div>       {rows.map(row => (         <Row           key={row._tempId}           row={row}           onChange={patch => updateRow(row._tempId, patch)}           onRemove={() => removeRow(row._tempId)}         />       ))}       <button type="button" onClick={addRow}>Add Row</button>       <pre>{JSON.stringify(rows, null, 2)}</pre>     </div>   ); } function Row({ row, onChange, onRemove }) {   return (     <div style={{ display: "flex", gap: 8, marginBottom: 8 }}>       <input         value={row.description}         onChange={e => onChange({ description: e.target.value })}         placeholder="Description"       />       <input         type="number"         value={row.quantity}         onChange={e => onChange({ quantity: Number(e.target.value) })}         style={{ width: 80 }}       />       <button type="button" onClick={onRemove}>Remove</button>     </div>   ); } 

Notes:

  • Use stable keys (_tempId) to avoid input losing focus.
  • When rows are saved, replace temp IDs with server IDs.

4. Validation strategies

Validation must cover per-row rules and any cross-row constraints.

Client-side validation:

  • Validate required fields, types, ranges immediately.
  • Show inline errors per field and a summary for the row.
  • Disable form submission until client-side constraints pass.

Server-side validation:

  • Mirror client rules and enforce additional constraints (uniqueness, DB limits).
  • Return structured error objects that map to row IDs/indices.

Example error structure returned from API:

{   "errors": {     "rows": {       "row_123": { "quantity": "Must be greater than 0" },       "row_124": { "description": "Required" }     },     "form": "Total items cannot exceed 100"   } } 

Match server errors to client rows using stable IDs (prefer server row IDs when available; otherwise use temp IDs that the server echoes back).


5. Reordering rows

If order matters, implement drag-and-drop or up/down controls.

  • Use established libraries: react-beautiful-dnd, dnd-kit, or native drag events.
  • Persist order as a numeric position column or an explicit array order in JSON.
  • For many items, consider gaps (10, 20, 30) for positions to minimize renumbering.

Simple up/down controls example:

function moveRow(rows, index, direction) {   const newRows = [...rows];   const swapIndex = index + (direction === "up" ? -1 : 1);   if (swapIndex < 0 || swapIndex >= rows.length) return rows;   [newRows[index], newRows[swapIndex]] = [newRows[swapIndex], newRows[index]];   return newRows; } 

6. Handling files and complex nested controls

If rows contain file inputs or rich widgets:

  • Upload files immediately on selection (to S3 or file service) and store references in the row. This avoids sending large multipart requests for entire forms.
  • Maintain upload state per-row (uploading, success, error).
  • For complex nested components (autocomplete, date pickers), memoize and isolate so performance stays snappy.

7. Accessibility

Make the repeating rows usable by keyboard and assistive tech:

  • Ensure Add/Remove buttons are focusable and labeled (aria-label).
  • For dynamic content, use aria-live regions for important changes (e.g., “Row added”).
  • If rows are reorderable, provide keyboard controls and announce changes.
  • Use fieldset/legend or appropriate grouping for each row, and ensure inputs have labels.

Example:

  • Buttons:

8. API design and payload shape

Design a straightforward request/response shape that supports creates, updates, deletes, and ordering.

Option A: send entire rows array each save (idempotent, simpler):

{   "rows": [     { "id": 1, "description": "Item A", "quantity": 2, "position": 1 },     { "id": null, "description": "New item", "quantity": 1, "position": 2 }   ] } 

Option B: diff-based changes (more efficient for large forms):

{   "create": [{ "description": "New item", "quantity": 1, "position": 3 }],   "update": [{ "id": 2, "quantity": 5 }],   "delete": [3] } 

Return server-assigned IDs for newly created rows and any validation errors tied to IDs or temp IDs.


9. Concurrency and conflict resolution

If multiple clients may edit the same parent record:

  • Use optimistic locking with a version or updated_at timestamp.
  • Detect conflicts on save and prompt the user to merge or overwrite.
  • For per-row collaborative edits, consider operational transforms or a conflict resolution strategy.

10. Performance and large lists

For long lists (hundreds of rows):

  • Virtualize the list (react-window, react-virtualized).
  • Persist frequently changed data gradually (autosave small diffs).
  • Debounce expensive computations and validations.

11. Testing

Test at multiple levels:

  • Unit tests for add/remove/update/reorder functions.
  • Integration tests for validation and API error mapping.
  • End-to-end tests to cover real user flows (add several rows, upload files, reorder, save).

12. Example end-to-end flow

  1. User opens a form with 2 line items loaded from server (server IDs included).
  2. User adds a row — client assigns a temp ID and uploads any files immediately.
  3. User edits fields; client-side validation runs and highlights issues.
  4. User reorders rows via drag-and-drop; client updates positions.
  5. User submits; client sends a diff payload with create/update/delete arrays.
  6. Server validates, saves, returns created IDs and errors mapped to temp IDs.
  7. Client replaces temp IDs with server IDs and displays any server-side errors.

Conclusion

A robust repeating rows component needs attention across UX, data modeling, validation, persistence, accessibility, and performance. Start with clear requirements, use stable IDs for client-server mapping, validate on both client and server, and choose storage (JSON vs relational) based on querying needs. With these practices, you’ll build a flexible, maintainable repeating-rows field that scales from simple forms to complex, data-heavy applications.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *