Skip to content

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.

Each component in a nested hierarchy manages its own draft state and participates in a recursive edit lifecycle:

  1. Receive initial data from parent (via props or typed provider).
  2. Hold a local draft copy in component state (useState / useReducer).
  3. Validate intrinsically — based on the component’s own nature (e.g., a date picker rejects Feb 30, an address editor requires a postal code).
  4. 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.
  5. 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 < until and both are in the future).
  6. 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 confirm
  • 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:

ConcernMechanismExample
Where does initial data come from?Typed provider hookuseAddressData(addressId) returns { data: PostalAddress }
How is the draft managed?Component-local stateconst [draft, setDraft] = useState(initialData)
How are changes pushed up?Typed callback propsonConfirm: (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).

[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.


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:

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 prop
interface 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 callbacks

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}
/>
);
}

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).

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>;

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>
);
}

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 hierarchy
function 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.

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 save
function 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.


To fully support the hierarchical edit lifecycle, the design system needs the following additions beyond the current FD-01 typed provider pattern:

// 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;
}

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.

Each editable component exports a validation function alongside itself:

// AddressEditor exports
export { 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 validation
function 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.

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 };
}
ExtensionLocationPurpose
ValidationResult, FieldError typesDesign system shared typesStandardized validation contract
EditLifecycleCallbacks<T> typeDesign system shared typesStandardized edit lifecycle props
EditableComponentProps<T> typeDesign system shared typesBase props for any editable component
useDraft<T> hookDesign system shared utilitiesReusable draft management with validation
validateXxx() export per componentDesign system, per componentComposable validation functions
contextErrors prop conventionDesign system, per componentParent-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