Abstract Component Lifecycle Design
Draft design for a generic, configurable component lifecycle framework that implements the hierarchical render-edit-validate-update cycle described in hierarchical-edit-lifecycle-exploration.md.
The goal is to provide reusable machinery so that every editable component in the design system follows the same lifecycle pattern without reimplementing the draft management, validation composition, error routing, and confirm/cancel protocol from scratch.
Overview Diagrams
Section titled “Overview Diagrams”Edit Phase State Machine
Section titled “Edit Phase State Machine”Every editable component using useDraft<T> transitions through these phases:
Type Hierarchy (Class Diagram)
Section titled “Type Hierarchy (Class Diagram)”Hierarchical Composition (Data Flow)
Section titled “Hierarchical Composition (Data Flow)”1. Design Principles
Section titled “1. Design Principles”-
Generic over specific — the framework operates on
<T>(any data shape). Concrete components instantiate it with their domain type (PostalAddress,OrderLine,ItemCardFields, etc.). -
Composable — a parent’s draft contains children’s drafts. Validation composes: child intrinsic + parent contextual. Errors route to the correct level automatically.
-
Framework-agnostic internals — the lifecycle machinery uses only React primitives (
useState,useReducer,useCallback,useContext). No TanStack Query, no Redux, no external dependencies. TanStack Query is used at the app level to source initial data and persist confirmed results, but the lifecycle itself is pure React. -
Opt-in complexity — simple leaf components (a text input) can use just
useDraftwith a trivial validator. Complex parent components can use the fulluseComposedDraftwith child registrations. The framework doesn’t impose overhead on simple cases. -
Observable — the lifecycle exposes its state (draft value, validation result, dirty flag, phase) so parent components and debugging tools can inspect it.
2. Core Types
Section titled “2. Core Types”// @arda-cards/design-system/types — shared lifecycle types
// ---------------------------------------------------------------------------// Validation// ---------------------------------------------------------------------------
/** A single validation error on a specific field. */interface FieldError { /** Dot-path to the field (e.g., "street1", "supplier.address.postalCode"). */ field: string; /** Human-readable error message. */ message: string; /** Machine-readable code for testing and i18n. */ code?: string; /** Severity — 'error' blocks confirm; 'warning' allows but shows message. */ severity?: 'error' | 'warning';}
/** Result of validating a value. */interface ValidationResult { /** True if no errors (warnings are allowed). */ valid: boolean; /** List of field-level errors and warnings. */ errors: FieldError[];}
/** A function that validates a value and returns a result. */type Validator<T> = (value: T) => ValidationResult;
// ---------------------------------------------------------------------------// Edit lifecycle// ---------------------------------------------------------------------------
/** The phase of an edit session. */type EditPhase = | 'idle' // Displaying confirmed value; no draft active | 'editing' // Draft active; user is making changes | 'confirming' // User clicked confirm; async validation or save in progress | 'error'; // Confirm failed; draft preserved for retry
/** Callbacks for the edit lifecycle. */interface EditLifecycleCallbacks<T> { /** * Called on every draft change. Includes the current validation result. * Parent can use this for live preview or cross-field validation. */ onChange?: (value: T, validation: ValidationResult) => void;
/** * Called when the user confirms the edit and intrinsic validation passes. * The parent may perform contextual validation and either accept or reject. */ onConfirm?: (value: T) => void;
/** * Called when the user cancels the edit. The parent should discard any * preview state derived from onChange calls. */ onCancel?: () => void;}
/** Standard props for any editable component. */interface EditableComponentProps<T> extends EditLifecycleCallbacks<T> { /** Initial value — from parent's draft or from a data provider. */ initialValue: T; /** Contextual errors injected by the parent after its own validation. */ contextErrors?: FieldError[]; /** Component is disabled (parent is saving, or a sibling edit is active). */ disabled?: boolean;}3. useDraft<T> — Core Draft Management Hook
Section titled “3. useDraft<T> — Core Draft Management Hook”The fundamental building block. Every editable component uses this hook to manage its local draft, run intrinsic validation, and expose lifecycle state.
3.1 Interface
Section titled “3.1 Interface”interface UseDraftOptions<T> { /** Initial value — resets the draft when this reference changes. */ initialValue: T; /** Intrinsic validator — called on every draft change. */ validate: Validator<T>; /** Contextual errors from parent — merged into the validation display. */ contextErrors?: FieldError[]; /** Lifecycle callbacks — forwarded from EditableComponentProps. */ onChange?: (value: T, validation: ValidationResult) => void; onConfirm?: (value: T) => void; onCancel?: () => void;}
interface DraftState<T> { /** Current draft value. */ value: T; /** Intrinsic validation result of the current draft. */ intrinsicValidation: ValidationResult; /** All errors: intrinsic + contextual, for display. */ allErrors: FieldError[]; /** Whether the draft differs from initialValue. */ dirty: boolean; /** Whether the draft is valid (intrinsic + no contextual errors). */ isValid: boolean; /** Current lifecycle phase. */ phase: EditPhase;
/** Update the draft. Runs validation and notifies parent via onChange. */ update: (updater: T | ((prev: T) => T)) => void; /** Update a single field by dot-path. */ updateField: (path: string, value: unknown) => void; /** Confirm the draft. Calls onConfirm if valid. */ confirm: () => void; /** Cancel the edit. Resets draft to initialValue and calls onCancel. */ cancel: () => void; /** Reset draft to initialValue without calling onCancel. */ reset: () => void; /** Get errors for a specific field (for rendering inline errors). */ errorsFor: (field: string) => FieldError[];}3.2 Implementation sketch
Section titled “3.2 Implementation sketch”function useDraft<T>(options: UseDraftOptions<T>): DraftState<T> { const { initialValue, validate, contextErrors = [], onChange, onConfirm, onCancel, } = options;
const [draft, setDraft] = useState<T>(initialValue); const [phase, setPhase] = useState<EditPhase>('idle'); const [intrinsicValidation, setIntrinsicValidation] = useState<ValidationResult>( () => validate(initialValue), );
// Reset when initialValue changes (parent confirmed, cancelled, or data refreshed) const initialValueRef = useRef(initialValue); useEffect(() => { if (!Object.is(initialValue, initialValueRef.current)) { initialValueRef.current = initialValue; setDraft(initialValue); setIntrinsicValidation(validate(initialValue)); setPhase('idle'); } }, [initialValue, validate]);
// Derived state const allErrors = useMemo( () => [...intrinsicValidation.errors, ...contextErrors], [intrinsicValidation.errors, contextErrors], );
const isValid = intrinsicValidation.valid && contextErrors.filter(e => e.severity !== 'warning').length === 0; const dirty = !Object.is(draft, initialValue); // Shallow — deep compare available via option
// Actions const update = useCallback( (updater: T | ((prev: T) => T)) => { setDraft((prev) => { const next = typeof updater === 'function' ? (updater as (prev: T) => T)(prev) : updater; const result = validate(next); setIntrinsicValidation(result); setPhase('editing'); onChange?.(next, result); return next; }); }, [validate, onChange], );
const updateField = useCallback( (path: string, value: unknown) => { update((prev) => setNestedField(prev, path, value)); }, [update], );
const confirm = useCallback(() => { const result = validate(draft); setIntrinsicValidation(result); if (result.valid && contextErrors.filter(e => e.severity !== 'warning').length === 0) { setPhase('confirming'); onConfirm?.(draft); // Phase transitions to 'idle' when parent updates initialValue } else { setPhase('error'); } }, [draft, validate, contextErrors, onConfirm]);
const cancel = useCallback(() => { setDraft(initialValue); setIntrinsicValidation(validate(initialValue)); setPhase('idle'); onCancel?.(); }, [initialValue, validate, onCancel]);
const reset = useCallback(() => { setDraft(initialValue); setIntrinsicValidation(validate(initialValue)); setPhase('idle'); }, [initialValue, validate]);
const errorsFor = useCallback( (field: string) => allErrors.filter((e) => e.field === field || e.field.startsWith(field + '.')), [allErrors], );
return { value: draft, intrinsicValidation, allErrors, dirty, isValid, phase, update, updateField, confirm, cancel, reset, errorsFor, };}3.3 Usage in a leaf component
Section titled “3.3 Usage in a leaf component”function PostalCodeInput({ initialValue, contextErrors, onChange, onConfirm, onCancel, disabled }: EditableComponentProps<string>) { const draft = useDraft({ initialValue, validate: (value) => { const errors: FieldError[] = []; if (!value) errors.push({ field: 'postalCode', message: 'Required' }); else if (!/^\d{5}(-\d{4})?$/.test(value)) errors.push({ field: 'postalCode', message: 'Invalid format' }); return { valid: errors.length === 0, errors }; }, contextErrors, onChange, onConfirm, onCancel, });
return ( <Input value={draft.value} onChange={(e) => draft.update(e.target.value)} error={draft.errorsFor('postalCode')[0]?.message} disabled={disabled} /> );}4. useComposedDraft<T> — Parent Draft with Child Composition
Section titled “4. useComposedDraft<T> — Parent Draft with Child Composition”For parent components that contain multiple editable children. Extends
useDraft with child registration and validation composition.
4.1 Interface
Section titled “4.1 Interface”interface ChildRegistration<TParent> { /** Dot-path in the parent draft where this child's value lives. */ path: string; /** Intrinsic validator for the child's value (reused from child component). */ validate: Validator<unknown>;}
interface UseComposedDraftOptions<T> extends UseDraftOptions<T> { /** Child components that contribute to this draft. */ children?: ChildRegistration<T>[]; /** Contextual validator — runs after all child validations pass. */ validateContextual?: (value: T) => ValidationResult;}
interface ComposedDraftState<T> extends DraftState<T> { /** Get the contextual errors to pass to a specific child. */ contextErrorsFor: (childPath: string) => FieldError[]; /** Handle a child's onChange — updates the parent draft at the child's path. */ handleChildChange: (childPath: string) => (value: unknown, validation: ValidationResult) => void; /** Handle a child's onConfirm — same as onChange but may trigger parent re-validation. */ handleChildConfirm: (childPath: string) => (value: unknown) => void;}4.2 Implementation sketch
Section titled “4.2 Implementation sketch”function useComposedDraft<T>(options: UseComposedDraftOptions<T>): ComposedDraftState<T> { const { children = [], validateContextual, ...draftOptions } = options;
// Compose validators: run child intrinsic validators + parent contextual const composedValidator = useCallback<Validator<T>>( (value: T) => { const errors: FieldError[] = [];
// Run the component's own intrinsic validation const intrinsic = draftOptions.validate(value); errors.push(...intrinsic.errors);
// Run each child's intrinsic validator on the relevant slice for (const child of children) { const childValue = getNestedField(value, child.path); const childResult = child.validate(childValue); // Namespace child errors under the child's path errors.push( ...childResult.errors.map((e) => ({ ...e, field: `${child.path}.${e.field}`, })), ); }
// Run contextual validation (cross-child, business rules) if (validateContextual) { const contextual = validateContextual(value); errors.push(...contextual.errors); }
return { valid: errors.filter(e => e.severity !== 'warning').length === 0, errors }; }, [draftOptions.validate, children, validateContextual], );
const draft = useDraft({ ...draftOptions, validate: composedValidator });
// Generate contextual errors for a specific child const contextErrorsFor = useCallback( (childPath: string): FieldError[] => { return draft.allErrors .filter((e) => e.field.startsWith(childPath + '.')) // Strip the child path prefix so the child sees its own field names .map((e) => ({ ...e, field: e.field.slice(childPath.length + 1) })) // Only include errors that came from contextual validation // (child's own intrinsic errors are managed by the child itself) .filter((e) => !draft.intrinsicValidation.errors.some( (ie) => ie.field === `${childPath}.${e.field}` && ie.message === e.message, )); }, [draft.allErrors, draft.intrinsicValidation.errors], );
// Handler factories for child components const handleChildChange = useCallback( (childPath: string) => (value: unknown, _validation: ValidationResult) => { draft.updateField(childPath, value); }, [draft.updateField], );
const handleChildConfirm = useCallback( (childPath: string) => (value: unknown) => { draft.updateField(childPath, value); // Parent may want to re-validate after child confirms }, [draft.updateField], );
return { ...draft, contextErrorsFor, handleChildChange, handleChildConfirm, };}4.3 Usage in a parent component
Section titled “4.3 Usage in a parent component”function AddressEditor({ initialValue, contextErrors, onChange, onConfirm, onCancel, disabled,}: EditableComponentProps<PostalAddress>) { const draft = useComposedDraft({ initialValue, validate: validateAddressIntrinsic, // required fields, format checks validateContextual: undefined, // no children with contextual needs at this level contextErrors, onChange, onConfirm, onCancel, });
return ( <div> <Input label="Street" value={draft.value.street1} onChange={(e) => draft.updateField('street1', e.target.value)} error={draft.errorsFor('street1')[0]?.message} disabled={disabled} /> <Input label="City" value={draft.value.city} onChange={(e) => draft.updateField('city', e.target.value)} error={draft.errorsFor('city')[0]?.message} disabled={disabled} /> <Input label="Postal Code" value={draft.value.postalCode} onChange={(e) => draft.updateField('postalCode', e.target.value)} error={draft.errorsFor('postalCode')[0]?.message} disabled={disabled} /> <div> <Button onClick={draft.confirm} disabled={!draft.isValid || disabled}> Confirm </Button> <Button variant="secondary" onClick={draft.cancel} disabled={disabled}> Cancel </Button> </div> </div> );}4.4 Usage in a multi-level parent
Section titled “4.4 Usage in a multi-level parent”function SupplierEditor({ initialValue, contextErrors, onChange, onConfirm, onCancel, disabled,}: EditableComponentProps<Supplier>) { const draft = useComposedDraft({ initialValue, validate: validateSupplierIntrinsic, validateContextual: (supplier) => { const errors: FieldError[] = []; // Contextual: address must be in supplier's service region if (supplier.address?.country && supplier.serviceRegion && supplier.address.country !== supplier.serviceRegion) { errors.push({ field: 'address.country', message: `Must be in service region: ${supplier.serviceRegion}`, }); } return { valid: errors.length === 0, errors }; }, children: [ { path: 'address', validate: validateAddressIntrinsic }, ], contextErrors, onChange, onConfirm, onCancel, });
return ( <div> <Input label="Supplier Name" value={draft.value.name} onChange={(e) => draft.updateField('name', e.target.value)} error={draft.errorsFor('name')[0]?.message} disabled={disabled} />
{/* AddressEditor is a self-contained editable component */} <AddressEditor initialValue={draft.value.address} contextErrors={draft.contextErrorsFor('address')} onChange={draft.handleChildChange('address')} onConfirm={draft.handleChildConfirm('address')} onCancel={() => draft.updateField('address', initialValue.address)} disabled={disabled} />
<Button onClick={draft.confirm} disabled={!draft.isValid || disabled}> Confirm Supplier </Button> <Button variant="secondary" onClick={draft.cancel} disabled={disabled}> Cancel </Button> </div> );}5. createEditableFactory — AG Grid Cell Editor Integration
Section titled “5. createEditableFactory — AG Grid Cell Editor Integration”For AG Grid cell editors, the lifecycle hook is wired through a factory
function (per FD-01 section 6.3). The factory creates a cell editor component
that uses useDraft internally and maps AG Grid’s edit lifecycle to the
framework’s lifecycle.
5.1 Interface
Section titled “5.1 Interface”interface CellEditorFactoryConfig<TValue> { /** Intrinsic validator for the cell value. */ validate?: Validator<TValue>; /** The React component that renders the editor UI. */ EditorComponent: React.ComponentType<CellEditorUIProps<TValue>>;}
/** Props passed to the editor UI component by the factory. */interface CellEditorUIProps<TValue> { draft: DraftState<TValue>; /** Row data for contextual rendering (e.g., show entity name in header). */ rowData: unknown; /** AG Grid column field name. */ field: string;}
function createCellEditorFactory<TValue>( config: CellEditorFactoryConfig<TValue>,): React.ForwardRefExoticComponent<ICellEditorParams>;5.2 Implementation sketch
Section titled “5.2 Implementation sketch”function createCellEditorFactory<TValue>(config: CellEditorFactoryConfig<TValue>) { const { validate = () => ({ valid: true, errors: [] }), EditorComponent } = config;
return forwardRef<ICellEditorComp, ICellEditorParams>((props, ref) => { const draft = useDraft<TValue>({ initialValue: props.value as TValue, validate, onConfirm: () => props.stopEditing(false), onCancel: () => props.stopEditing(true), });
useImperativeHandle(ref, () => ({ getValue: () => draft.value, isCancelAfterEnd: () => !draft.dirty, isPopup: () => true, }));
return ( <EditorComponent draft={draft} rowData={props.data} field={props.column.getColDef().field ?? ''} /> ); });}5.3 Usage
Section titled “5.3 Usage”// Simple text cell editor — uses the factory with built-in draft lifecycleconst TextCellEditor = createCellEditorFactory<string>({ validate: (value) => ({ valid: value.trim().length > 0, errors: value.trim().length === 0 ? [{ field: 'value', message: 'Required' }] : [], }), EditorComponent: ({ draft }) => ( <input value={draft.value} onChange={(e) => draft.update(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') draft.confirm(); if (e.key === 'Escape') draft.cancel(); }} autoFocus /> ),});
// Typeahead cell editor — factory with injected lookup providerfunction createTypeaheadCellEditorWithLifecycle(config: { useLookup: LookupHook; validate?: Validator<string>;}) { return createCellEditorFactory<string>({ validate: config.validate ?? (() => ({ valid: true, errors: [] })), EditorComponent: ({ draft }) => { const { data: options, isLoading } = config.useLookup(draft.value); return ( <TypeaheadUI value={draft.value} options={options ?? []} loading={isLoading} onChange={(v) => draft.update(v)} onSelect={(v) => { draft.update(v); draft.confirm(); }} onCancel={draft.cancel} /> ); }, });}6. Validation Function Convention
Section titled “6. Validation Function Convention”Every editable component exports its intrinsic validation function alongside the component. This enables:
- Parents to compose child validation into their own
- Pre-validation before rendering a child
- Testing validation logic independently from rendering
6.1 Convention
Section titled “6.1 Convention”export { AddressEditor } from './address-editor';export { validateAddress } from './validate-address';export type { AddressEditorProps } from './types';
// validate-address.ts — pure function, no Reactexport function validateAddress(address: PostalAddress): ValidationResult { const errors: FieldError[] = []; if (!address.street1?.trim()) errors.push({ field: 'street1', message: 'Street is required' }); if (!address.city?.trim()) errors.push({ field: 'city', message: 'City is required' }); if (!address.postalCode?.trim()) errors.push({ field: 'postalCode', message: 'Postal code is required' }); if (address.postalCode && !/^\d{5}(-\d{4})?$/.test(address.postalCode)) { errors.push({ field: 'postalCode', message: 'Invalid postal code format' }); } if (!address.country?.trim()) errors.push({ field: 'country', message: 'Country is required' }); return { valid: errors.length === 0, errors };}6.2 Composition in parent
Section titled “6.2 Composition in parent”import { validateAddress } from '../address-editor';
export function validateSupplier(supplier: Supplier): ValidationResult { const errors: FieldError[] = [];
// Own intrinsic if (!supplier.name?.trim()) errors.push({ field: 'name', message: 'Name is required' });
// Compose child validation (namespaced) if (supplier.address) { const addressResult = validateAddress(supplier.address); errors.push(...addressResult.errors.map(e => ({ ...e, field: `address.${e.field}` }))); }
return { valid: errors.length === 0, errors };}7. EditablePanel — Optional High-Level Wrapper
Section titled “7. EditablePanel — Optional High-Level Wrapper”For common patterns (form with confirm/cancel footer, dirty-state warning on navigate away), an optional wrapper component reduces boilerplate:
interface EditablePanelProps<T> extends EditableComponentProps<T> { /** Intrinsic validator. */ validate: Validator<T>; /** Panel title. */ title: string; /** Confirm button label. Default: "Save". */ confirmLabel?: string; /** Cancel button label. Default: "Cancel". */ cancelLabel?: string; /** Whether to show a "discard changes?" dialog on cancel when dirty. */ warnOnCancel?: boolean; /** Render function receiving the draft state. */ children: (draft: DraftState<T>) => ReactNode;}
function EditablePanel<T>({ initialValue, validate, contextErrors, onChange, onConfirm, onCancel, disabled, title, confirmLabel = 'Save', cancelLabel = 'Cancel', warnOnCancel = true, children,}: EditablePanelProps<T>) { const draft = useDraft({ initialValue, validate, contextErrors, onChange, onConfirm, onCancel }); const [warnOpen, setWarnOpen] = useState(false);
const handleCancel = () => { if (warnOnCancel && draft.dirty) { setWarnOpen(true); } else { draft.cancel(); } };
return ( <div> <h2>{title}</h2> {children(draft)} <footer> <Button onClick={draft.confirm} disabled={!draft.isValid || disabled}> {confirmLabel} </Button> <Button variant="secondary" onClick={handleCancel} disabled={disabled}> {cancelLabel} </Button> </footer> <ArdaConfirmDialog open={warnOpen} title="Discard changes?" message="You have unsaved changes. Discard them?" confirmLabel="Discard" confirmVariant="destructive" onConfirm={() => { setWarnOpen(false); draft.cancel(); }} onCancel={() => setWarnOpen(false)} /> </div> );}Usage:
<EditablePanel initialValue={supplier} validate={validateSupplier} title="Edit Supplier" onConfirm={(confirmed) => updateMutation.mutate(confirmed)} onCancel={() => router.back()}> {(draft) => ( <> <Input label="Name" value={draft.value.name} onChange={(e) => draft.updateField('name', e.target.value)} error={draft.errorsFor('name')[0]?.message} /> <AddressEditor initialValue={draft.value.address} contextErrors={draft.errorsFor('address')} onChange={(addr) => draft.updateField('address', addr)} /> </> )}</EditablePanel>8. Component Inventory
Section titled “8. Component Inventory”Summary of the framework’s public API:
Types (exported from @arda-cards/design-system/types)
Section titled “Types (exported from @arda-cards/design-system/types)”| Type | Purpose |
|---|---|
FieldError | Per-field error with path, message, code, severity |
ValidationResult | { valid, errors } |
Validator<T> | (value: T) => ValidationResult |
EditPhase | 'idle' | 'editing' | 'confirming' | 'error' |
EditLifecycleCallbacks<T> | onChange, onConfirm, onCancel |
EditableComponentProps<T> | initialValue + callbacks + contextErrors + disabled |
Hooks (exported from @arda-cards/design-system/canary)
Section titled “Hooks (exported from @arda-cards/design-system/canary)”| Hook | Purpose |
|---|---|
useDraft<T> | Core draft management with validation and lifecycle |
useComposedDraft<T> | Parent draft composing child validations and error routing |
Factories (exported from @arda-cards/design-system/canary)
Section titled “Factories (exported from @arda-cards/design-system/canary)”| Factory | Purpose |
|---|---|
createCellEditorFactory<T> | AG Grid cell editor with built-in draft lifecycle |
Components (optional, exported from @arda-cards/design-system/canary)
Section titled “Components (optional, exported from @arda-cards/design-system/canary)”| Component | Purpose |
|---|---|
EditablePanel<T> | High-level form wrapper with confirm/cancel/warn-on-dirty |
Utilities (exported alongside each component)
Section titled “Utilities (exported alongside each component)”| Convention | Purpose |
|---|---|
validateXxx() per component | Composable intrinsic validation function |
9. Relationship to Existing Patterns
Section titled “9. Relationship to Existing Patterns”Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved