Skip to content

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.


Every editable component using useDraft<T> transitions through these phases:

PlantUML diagram

PlantUML diagram

PlantUML diagram


  1. Generic over specific — the framework operates on <T> (any data shape). Concrete components instantiate it with their domain type (PostalAddress, OrderLine, ItemCardFields, etc.).

  2. Composable — a parent’s draft contains children’s drafts. Validation composes: child intrinsic + parent contextual. Errors route to the correct level automatically.

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

  4. Opt-in complexity — simple leaf components (a text input) can use just useDraft with a trivial validator. Complex parent components can use the full useComposedDraft with child registrations. The framework doesn’t impose overhead on simple cases.

  5. Observable — the lifecycle exposes its state (draft value, validation result, dirty flag, phase) so parent components and debugging tools can inspect it.


// @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.

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[];
}
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,
};
}
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.

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

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>;
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 ?? ''}
/>
);
});
}
// Simple text cell editor — uses the factory with built-in draft lifecycle
const 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 provider
function 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}
/>
);
},
});
}

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
address-editor/index.ts
export { AddressEditor } from './address-editor';
export { validateAddress } from './validate-address';
export type { AddressEditorProps } from './types';
// validate-address.ts — pure function, no React
export 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 };
}
supplier-editor/validate-supplier.ts
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>

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)”
TypePurpose
FieldErrorPer-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)”
HookPurpose
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)”
FactoryPurpose
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)”
ComponentPurpose
EditablePanel<T>High-level form wrapper with confirm/cancel/warn-on-dirty

Utilities (exported alongside each component)

Section titled “Utilities (exported alongside each component)”
ConventionPurpose
validateXxx() per componentComposable intrinsic validation function

PlantUML diagram


Copyright: (c) Arda Systems 2025-2026, All rights reserved