Hierarchical Edit Lifecycle Exploration
Exploration of a hierarchical edit-confirm-cancel pattern for nested component compositions, and how it integrates with the typed provider architecture established in FD-01.
1. The Pattern
Section titled “1. The Pattern”Each component in a nested hierarchy manages its own draft state and participates in a recursive edit lifecycle:
- Receive initial data from parent (via props or typed provider).
- Hold a local draft copy in component state (
useState/useReducer). - Validate intrinsically — based on the component’s own nature (e.g., a date picker rejects Feb 30, an address editor requires a postal code).
- Accept updates from children — when a child’s edit cycle completes, the child pushes its confirmed value to the parent, which incorporates it into its own draft.
- Validate contextually — the parent validates the child’s value in the
context of its siblings and its own constraints (e.g., a date range editor
validates that
from < untiland both are in the future). - Confirm or cancel — on confirm, push the validated draft to the parent above. On cancel, discard the draft and restore the initial state.
OrderEditor (draft Order)├── SupplierSection (draft Supplier selection)│ └── AddressEditor (draft PostalAddress)│ ├── intrinsic: postal code format, required fields│ └── → pushes PostalAddress to SupplierSection on confirm│ ├── intrinsic: supplier must be selected│ ├── contextual: address must be in a serviceable region for this order type│ └── → pushes Supplier to OrderEditor on confirm├── DateRangeEditor (draft DateRange)│ ├── FromDatePicker (draft Date)│ │ ├── intrinsic: valid date (no Feb 30)│ │ └── → pushes Date to DateRangeEditor on change│ ├── UntilDatePicker (draft Date)│ │ ├── intrinsic: valid date│ │ └── → pushes Date to DateRangeEditor on change│ ├── contextual: from < until, both in the future│ └── → pushes DateRange to OrderEditor on confirm├── OrderLinesEditor (draft OrderLine[])│ └── OrderLineEditor (draft OrderLine)│ ├── intrinsic: quantity > 0, item selected│ ├── contextual: item not already in another line│ └── → pushes OrderLine to OrderLinesEditor on confirm├── contextual: validates supplier + dates + lines are coherent│ (e.g., supplier must be able to fulfill the items in the lines)└── → pushes Order to save handler on confirmKey properties
Section titled “Key properties”- Each level is self-contained — a component doesn’t know (or care) how many levels above or below it exist. It receives initial data, manages its own draft, validates, and pushes the result up.
- Validation is layered — intrinsic validation is local to the component’s field type; contextual validation is applied by the parent that knows the broader context. Errors surface at the appropriate level.
- Cancel propagates from any level — cancelling at any level discards that level’s draft and restores the initial state. Children below a cancelled parent lose their drafts too (the parent re-renders with initial data, resetting children).
- Confirm is bottom-up — a parent cannot confirm until all children with active edits have either confirmed or cancelled.
2. Compatibility with FD-01 (Typed Providers)
Section titled “2. Compatibility with FD-01 (Typed Providers)”FD-01 establishes that design system components use typed data provider hooks for their data needs. The hierarchical edit lifecycle is orthogonal to and compatible with this pattern. They serve different concerns:
| Concern | Mechanism | Example |
|---|---|---|
| Where does initial data come from? | Typed provider hook | useAddressData(addressId) returns { data: PostalAddress } |
| How is the draft managed? | Component-local state | const [draft, setDraft] = useState(initialData) |
| How are changes pushed up? | Typed callback props | onConfirm: (value: PostalAddress) => void |
| How is the result persisted? | App-level mutation (TanStack) | useUpdateOrder().mutate(confirmedOrder) |
The typed provider gives the component its initial data. The edit lifecycle manages the draft. The callback props push confirmed results up. The app decides when and how to persist (which may only happen at the top level).
Data flow diagram
Section titled “Data flow diagram”[TanStack Query cache] │ ▼ (typed provider: useOrderData) OrderEditor │ initialData = query.data ▼ [local draft: useState(initialData)] │ ├── SupplierSection │ │ initialData = draft.supplier │ ▼ │ [local draft: useState(initialData)] │ │ │ └── AddressEditor │ │ initialData = supplierDraft.address │ ▼ │ [local draft: useState(initialData)] │ │ │ └── onConfirm(confirmedAddress) │ │ │ ▼ (parent validates contextually) │ supplierDraft.address = confirmedAddress │ │ └── onConfirm(confirmedSupplier) │ │ │ ▼ (parent validates contextually) │ orderDraft.supplier = confirmedSupplier │ └── onConfirm(confirmedOrder) │ ▼ (app persists) useUpdateOrder().mutate(confirmedOrder) │ ▼ queryClient.invalidateQueries(['order', orderId])The typed provider is only at the entry point (top-level component loading initial data from the server). Below that, data flows via props and callbacks — no additional providers needed for nested components.
3. Component Contract Extensions
Section titled “3. Component Contract Extensions”The current design system components have a simple contract:
interface AddressCardProps { addressId: string; useAddressData: AddressDataHook;}The hierarchical edit lifecycle extends this with edit mode concerns:
3.1 Read-only vs. editable mode
Section titled “3.1 Read-only vs. editable mode”Components need to support both display (read-only) and edit (draft management) modes. This can be a single component with mode props or separate components:
// Option 1: Single component, mode propinterface AddressEditorProps { /** Initial value — from parent's draft or from a typed provider. */ value: PostalAddress | null; /** Edit mode callbacks. When omitted, component renders read-only. */ onChange?: (value: PostalAddress) => void; onConfirm?: (value: PostalAddress) => void; onCancel?: () => void; /** Contextual errors injected by the parent after its validation. */ contextErrors?: string[]; /** Whether the parent is in a saving/confirming state. */ disabled?: boolean;}// Option 2: Separate components sharing types// AddressDisplay — read-only, used in detail views// AddressEditor — editable, used in forms// Both accept PostalAddress, but Editor adds lifecycle callbacks3.2 Validation contract
Section titled “3.2 Validation contract”Each component manages two categories of validation:
/** Result of a component's intrinsic validation. */interface ValidationResult { valid: boolean; errors: FieldError[];}
interface FieldError { field: string; // Which field within the component message: string; // Human-readable error message code?: string; // Machine-readable error code for testing}Intrinsic validation is performed internally by the component on its own draft. The component exposes its validation state:
interface AddressEditorProps { value: PostalAddress | null; onChange?: (value: PostalAddress, validation: ValidationResult) => void; // Parent receives both the value AND the validation result}Contextual validation is performed by the parent and injected back into the child as error messages:
interface AddressEditorProps { value: PostalAddress | null; onChange?: (value: PostalAddress, validation: ValidationResult) => void; /** Errors from the parent's contextual validation. */ contextErrors?: FieldError[];}The parent validates after receiving onChange:
function SupplierSection({ supplier, onConfirm }: Props) { const [draft, setDraft] = useState(supplier); const [addressErrors, setAddressErrors] = useState<FieldError[]>([]);
const handleAddressChange = useCallback((address: PostalAddress, validation: ValidationResult) => { setDraft(prev => ({ ...prev, address }));
// Contextual validation: address must be in supplier's service region if (address.country !== draft.serviceRegion) { setAddressErrors([{ field: 'country', message: `Address must be in ${draft.serviceRegion} for this supplier`, }]); } else { setAddressErrors([]); } }, [draft.serviceRegion]);
return ( <AddressEditor value={draft.address} onChange={handleAddressChange} contextErrors={addressErrors} /> );}3.3 Edit lifecycle callbacks
Section titled “3.3 Edit lifecycle callbacks”The full callback contract for an editable component:
interface EditLifecycleCallbacks<T> { /** Called on every change to the draft. Includes intrinsic validation. */ onChange: (value: T, validation: ValidationResult) => void;
/** Called when the user confirms the edit. Only fires if intrinsically valid. */ onConfirm: (value: T) => void;
/** Called when the user cancels. Parent should restore initial value. */ onCancel: () => void;}Components that support the edit lifecycle expose these callbacks. Components that are display-only omit them (the distinction can be a type union or simply optional props).
3.4 Generic editable component pattern
Section titled “3.4 Generic editable component pattern”A reusable pattern for any component in the hierarchy:
interface EditableComponentProps<T> { /** Initial value from parent. */ initialValue: T; /** Contextual errors from parent's validation. */ contextErrors?: FieldError[]; /** Whether the component is disabled (parent is saving, etc.). */ disabled?: boolean; /** Lifecycle callbacks. */ onChange?: (value: T, validation: ValidationResult) => void; onConfirm?: (value: T) => void; onCancel?: () => void;}Each concrete component (AddressEditor, DateRangeEditor, etc.) implements
this pattern with its specific type T:
type AddressEditorProps = EditableComponentProps<PostalAddress>;type DateRangeEditorProps = EditableComponentProps<DateRange>;type OrderLineEditorProps = EditableComponentProps<OrderLine>;3.5 Draft management hook
Section titled “3.5 Draft management hook”A reusable hook that encapsulates the draft lifecycle for any component:
// In @arda-cards/design-system — reusable draft management
interface UseDraftOptions<T> { initialValue: T; validate: (value: T) => ValidationResult; onChange?: (value: T, validation: ValidationResult) => void;}
function useDraft<T>({ initialValue, validate, onChange }: UseDraftOptions<T>) { const [draft, setDraft] = useState<T>(initialValue); const [validation, setValidation] = useState<ValidationResult>({ valid: true, errors: [] });
// Reset draft when initialValue changes (parent confirmed or cancelled) useEffect(() => { setDraft(initialValue); setValidation(validate(initialValue)); }, [initialValue]);
const updateDraft = useCallback((updater: (prev: T) => T) => { setDraft(prev => { const next = updater(prev); const result = validate(next); setValidation(result); onChange?.(next, result); return next; }); }, [validate, onChange]);
const reset = useCallback(() => { setDraft(initialValue); setValidation(validate(initialValue)); }, [initialValue, validate]);
return { draft, validation, updateDraft, reset };}Usage in a concrete component:
function AddressEditor({ initialValue, contextErrors, onChange, onConfirm, onCancel }: AddressEditorProps) { const { draft, validation, updateDraft, reset } = useDraft({ initialValue, validate: validateAddress, // intrinsic validation onChange, });
// Combine intrinsic + contextual errors for display const allErrors = [...validation.errors, ...(contextErrors ?? [])]; const isValid = validation.valid && (contextErrors?.length ?? 0) === 0;
return ( <form> <Input label="Street" value={draft.street1} onChange={(e) => updateDraft(d => ({ ...d, street1: e.target.value }))} error={allErrors.find(e => e.field === 'street1')?.message} /> {/* ... other fields */} <Button onClick={() => onConfirm?.(draft)} disabled={!isValid}>Confirm</Button> <Button variant="secondary" onClick={() => { reset(); onCancel?.(); }}>Cancel</Button> </form> );}4. Integration with AG Grid Cell Editors
Section titled “4. Integration with AG Grid Cell Editors”AG Grid cell editors are a special case of the hierarchical edit pattern:
- The grid row is the parent holding the entity draft.
- The cell editor is the child managing a single field’s draft.
- On
stopEditing(false), the cell pushes its confirmed value to the grid. - On
stopEditing(true), the cell cancels and the grid restores the original value.
For complex cell editors (e.g., an address editor popup), the cell editor becomes a mini-hierarchy:
AG Grid row (entity draft)└── AddressCellEditor (popup) └── AddressEditor (draft PostalAddress) ├── intrinsic validation (postal code, required fields) ├── onConfirm → CellEditor calls stopEditing(false) with new value └── onCancel → CellEditor calls stopEditing(true)The factory pattern from FD-01 (section 6.3) wires the validation and lifecycle:
export function createAddressCellEditor(config: { validate?: (address: PostalAddress, rowData: EntityRow) => FieldError[];}) { return forwardRef<ICellEditorComp, ICellEditorParams>((props, ref) => { const [value, setValue] = useState<PostalAddress>(props.value);
useImperativeHandle(ref, () => ({ getValue: () => value, isPopup: () => true, }));
// Contextual validation: parent (grid) provides row-level context const contextErrors = config.validate?.(value, props.data) ?? [];
return ( <AddressEditor initialValue={props.value} contextErrors={contextErrors} onConfirm={(confirmed) => { setValue(confirmed); props.stopEditing(false); }} onCancel={() => props.stopEditing(true)} /> ); });}5. Integration with TanStack Query Mutations
Section titled “5. Integration with TanStack Query Mutations”The hierarchical edit pattern defers persistence to the top level. Only when the outermost component’s edit cycle completes does the app persist the result via a TanStack mutation:
// App-level page — top of the hierarchyfunction OrderEditPage({ orderId }: { orderId: string }) { const { data: order } = useQuery({ queryKey: ['order', orderId], queryFn: () => getOrder(orderId), });
const updateOrder = useMutation({ mutationFn: (input: OrderInput) => api.updateOrder(orderId, input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['order', orderId] }); }, });
if (!order) return <Loading />;
return ( <OrderEditor initialValue={order} onConfirm={(confirmedOrder) => { // Only now do we hit the server updateOrder.mutate(confirmedOrder); }} onCancel={() => router.back()} disabled={updateOrder.isPending} /> );}Nested components never trigger server calls directly. They push state up through the hierarchy. The app decides when to persist. This means:
- No accidental partial saves — child components cannot persist independently. The entity is saved as a coherent whole.
- Optimistic updates are straightforward — the app can apply the confirmed draft to the TanStack cache immediately, then reconcile with the server response.
- Error handling is centralized — if the server rejects the mutation, the app shows the error and can restore the draft for re-editing.
Alternative: per-field persistence
Section titled “Alternative: per-field persistence”Some UIs save each field individually (the current item grid pattern:
edit a cell → auto-publish draft). This is a different lifecycle where
each child’s onConfirm triggers a server mutation:
// Per-field persistence — each cell edit triggers a savefunction handleCellConfirm(field: string, value: unknown) { updateItem.mutate({ entityId, input: { [field]: value } });}The hierarchical edit pattern supports both models — the parent
decides whether onConfirm triggers local draft incorporation (bulk save)
or immediate persistence (per-field save). The child doesn’t know which.
6. Extensions Needed
Section titled “6. Extensions Needed”To fully support the hierarchical edit lifecycle, the design system needs the following additions beyond the current FD-01 typed provider pattern:
6.1 Standardized edit lifecycle types
Section titled “6.1 Standardized edit lifecycle types”// New shared types in @arda-cards/design-system/types
interface ValidationResult { valid: boolean; errors: FieldError[];}
interface FieldError { field: string; message: string; code?: string;}
interface EditLifecycleCallbacks<T> { onChange?: (value: T, validation: ValidationResult) => void; onConfirm?: (value: T) => void; onCancel?: () => void;}
interface EditableComponentProps<T> extends EditLifecycleCallbacks<T> { initialValue: T; contextErrors?: FieldError[]; disabled?: boolean;}6.2 useDraft hook
Section titled “6.2 useDraft hook”A reusable hook for draft management with intrinsic validation (as shown in section 3.5). This is a design system utility — it encapsulates the draft lifecycle pattern so each component doesn’t reimplement it.
6.3 Validation function convention
Section titled “6.3 Validation function convention”Each editable component exports a validation function alongside itself:
// AddressEditor exportsexport { AddressEditor } from './address-editor';export { validateAddress } from './validate-address';export type { AddressEditorProps } from './types';
// The validation function can be used by parents for contextual validation// or by the component itself for intrinsic validationfunction validateAddress(address: PostalAddress): ValidationResult { const errors: FieldError[] = []; if (!address.street1) errors.push({ field: 'street1', message: 'Street is required' }); if (!address.postalCode) errors.push({ field: 'postalCode', message: 'Postal code is required' }); if (!address.country) errors.push({ field: 'country', message: 'Country is required' }); return { valid: errors.length === 0, errors };}This enables parents to pre-validate child data before even rendering the child, and to compose validation logic across the hierarchy.
6.4 Composable validation
Section titled “6.4 Composable validation”Parents compose child validation with their own contextual rules:
function validateSupplier(supplier: SupplierDraft): ValidationResult { const errors: FieldError[] = [];
// Intrinsic if (!supplier.name) errors.push({ field: 'name', message: 'Supplier name is required' });
// Compose child validation const addressResult = validateAddress(supplier.address); errors.push(...addressResult.errors.map(e => ({ ...e, field: `address.${e.field}`, // namespace for error routing })));
// Contextual (based on parent knowledge) if (supplier.address.country !== supplier.serviceRegion) { errors.push({ field: 'address.country', message: `Must be in service region: ${supplier.serviceRegion}`, }); }
return { valid: errors.length === 0, errors };}6.5 Summary of extensions
Section titled “6.5 Summary of extensions”| Extension | Location | Purpose |
|---|---|---|
ValidationResult, FieldError types | Design system shared types | Standardized validation contract |
EditLifecycleCallbacks<T> type | Design system shared types | Standardized edit lifecycle props |
EditableComponentProps<T> type | Design system shared types | Base props for any editable component |
useDraft<T> hook | Design system shared utilities | Reusable draft management with validation |
validateXxx() export per component | Design system, per component | Composable validation functions |
contextErrors prop convention | Design system, per component | Parent-injected contextual errors |
None of these conflict with FD-01. The typed provider pattern handles data sourcing; the edit lifecycle handles state management and validation. They are complementary layers.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved