Final StretchOrdered learning track

Case Study: Enterprise Form Builder

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 115

Case study production-grade enterprise form builder: schema, registry, validation graph, conditional fields, dynamic arrays, persistence, server validation, accessibility, testing, and observability.

9 min read1603 words
PrevNext
Lesson 115123 lesson track102–123 Final Stretch
#react#hooks#state-management#forms+4 more

Part 115 — Case Study: Enterprise Form Builder

Enterprise form builder terdengar seperti “render form dari JSON”. Itu framing yang terlalu kecil.

Di aplikasi sungguhan, enterprise form builder adalah runtime UI untuk mengumpulkan data, menjaga invariant, mengorkestrasi validasi, mengontrol izin, menyimpan draft, menangani perubahan schema, dan menghasilkan bukti bahwa data dikumpulkan dengan aturan yang benar.

Form builder yang buruk biasanya gagal bukan karena tidak bisa render <input />, tetapi karena:

  • schema berubah setelah form sudah punya draft lama,
  • field conditional membuat data tersembunyi tetap terkirim,
  • validasi cross-field tersebar di component,
  • array/repeater kehilangan identity saat reorder,
  • server validation dan client validation tidak punya contract sama,
  • permission hanya dipakai untuk hide UI, bukan menjaga command boundary,
  • state field disimpan terlalu global sehingga setiap keystroke merender seluruh form,
  • form tidak observable sehingga bug produksi tidak bisa direkonstruksi.

Target part ini: membangun mental model dan blueprint implementasi form builder yang bisa dipakai untuk form kompleks: onboarding, regulatory case intake, risk assessment, claim submission, loan application, approval packet, internal workflow, atau compliance checklist.


1. Problem Statement

Kita ingin membangun form builder dengan kemampuan berikut:

Schema-driven rendering
Conditional visibility
Conditional requiredness
Cross-field validation
Dynamic field arrays
Server validation
Draft persistence
Versioned schema migration
Permission-aware fields
Accessible error reporting
Observable submit workflow
Testable rules and transitions

Yang tidak kita inginkan:

JSON raksasa yang berubah menjadi bahasa pemrograman buruk
Component yang membaca schema langsung lalu punya ratusan if
Setiap field subscribe ke seluruh form state
Validation logic tersebar di 40 component
Submit handler yang menjadi procedural monster

Form builder harus dipisahkan menjadi beberapa engine kecil.


2. Core Mental Model

Enterprise form builder bukan satu component. Ia adalah komposisi beberapa subsystem:

Prinsipnya:

Schema describes structure.
Registry maps type to implementation.
State store owns current values and meta.
Dependency graph knows what depends on what.
Validation engine produces errors.
Renderer maps resolved form model to UI.
Submit command is the only boundary to server mutation.

Jangan biarkan satu layer melakukan semua pekerjaan.


3. Domain Vocabulary

Gunakan vocabulary yang stabil dari awal.

TermMeaning
FieldUnit input paling kecil, misalnya text, date, select, money
GroupLogical container untuk beberapa field
SectionVisual/domain grouping lebih besar
RepeaterDynamic array of item groups
SchemaVersioned definition of form structure and rules
Field registryMapping dari field.type ke renderer/adapter
Value stateData yang akan dikirim
Meta statetouched, dirty, visited, validating, disabled reason
Error modelClient/server/business errors keyed by field/path
Visibility ruleMenentukan field/section ditampilkan atau tidak
Requiredness ruleMenentukan wajib/tidak berdasarkan state/context
Validation ruleMenentukan valid/tidak
DraftPersisted partial state yang belum submit
SubmissionCommand lifecycle menuju server

Hal kecil tapi penting: value state dan meta state jangan dicampur. values.customerName adalah data domain. meta.customerName.touched adalah UI state.


4. State Topology

Enterprise form builder punya banyak jenis state.

StateOwnerVolatilitySerializableNotes
Schemaserver/config/modulelowyesversioned
Valuesform enginehighyessubmit payload source
Metaform enginehighpartlytouched/dirty/visited
Visibilityderived enginemediumnoderived from schema + values
Requirednessderived enginemediumnoderived, not stored
Errorsvalidation enginemediumyesclient + server errors
Submissioncommand layerlow-mediummaybepending/succeeded/failed
Draft persistencepersistence adaptermediumyesversioned snapshot
Field focusUI component/refhighnoephemeral

Rule sederhana:

Store user-provided facts.
Derive everything that can be deterministically derived.
Persist only what can survive schema/version changes.

5. Schema Shape

Mulai dari schema yang cukup kuat tetapi tidak menjadi bahasa programming bebas.

export type FormSchema = {
  schemaVersion: string;
  formId: string;
  title: string;
  sections: SectionSchema[];
};

export type SectionSchema = {
  id: string;
  title: string;
  description?: string;
  visibleWhen?: RuleExpr;
  fields: FieldSchema[];
};

export type FieldSchema =
  | TextFieldSchema
  | NumberFieldSchema
  | DateFieldSchema
  | SelectFieldSchema
  | RepeaterFieldSchema;

type BaseFieldSchema = {
  id: string;
  path: string;
  label: string;
  helpText?: string;
  requiredWhen?: RuleExpr;
  visibleWhen?: RuleExpr;
  disabledWhen?: RuleExpr;
  validate?: ValidationRule[];
  permissions?: FieldPermissionRule;
};

export type TextFieldSchema = BaseFieldSchema & {
  type: 'text';
  placeholder?: string;
  maxLength?: number;
};

export type SelectFieldSchema = BaseFieldSchema & {
  type: 'select';
  options: Array<{ value: string; label: string }>;
};

export type RepeaterFieldSchema = BaseFieldSchema & {
  type: 'repeater';
  minItems?: number;
  maxItems?: number;
  itemFields: FieldSchema[];
};

Kuncinya bukan punya semua field type dari awal. Kuncinya adalah membuat schema punya contract yang stabil:

id      = identity untuk schema node
path    = address value dalam payload
label   = accessible label
rules   = expression yang dievaluasi engine
type    = lookup ke field registry

6. Jangan Pakai Function Arbitrary di Schema Remote

Ini menggoda:

{
  visibleWhen: (values) => values.country === 'ID'
}

Untuk schema lokal compile-time, function masih bisa diterima. Untuk schema remote/configurable, jangan.

Masalahnya:

  • tidak serializable,
  • tidak bisa divalidasi statically,
  • sulit versioning,
  • sulit audit,
  • berisiko security,
  • sulit dijalankan di server untuk validasi ulang.

Gunakan expression AST terbatas.

export type RuleExpr =
  | { op: 'equals'; path: string; value: unknown }
  | { op: 'notEquals'; path: string; value: unknown }
  | { op: 'exists'; path: string }
  | { op: 'and'; rules: RuleExpr[] }
  | { op: 'or'; rules: RuleExpr[] }
  | { op: 'not'; rule: RuleExpr };

Evaluator:

export function evaluateRule(rule: RuleExpr | undefined, values: unknown): boolean {
  if (!rule) return true;

  switch (rule.op) {
    case 'equals':
      return getPath(values, rule.path) === rule.value;

    case 'notEquals':
      return getPath(values, rule.path) !== rule.value;

    case 'exists':
      return getPath(values, rule.path) != null;

    case 'and':
      return rule.rules.every((child) => evaluateRule(child, values));

    case 'or':
      return rule.rules.some((child) => evaluateRule(child, values));

    case 'not':
      return !evaluateRule(rule.rule, values);

    default: {
      const _exhaustive: never = rule;
      return _exhaustive;
    }
  }
}

This is boring on purpose. Boring rules are testable rules.


7. Field Registry

Renderer tidak boleh tahu cara render setiap field melalui switch raksasa yang terus membesar.

Gunakan registry.

export type FieldRendererProps<TField extends FieldSchema = FieldSchema> = {
  field: TField;
  value: unknown;
  error?: FieldError;
  meta: FieldMeta;
  disabled: boolean;
  required: boolean;
  onChange: (value: unknown) => void;
  onBlur: () => void;
};

export type FieldRenderer<TField extends FieldSchema = FieldSchema> =
  (props: FieldRendererProps<TField>) => React.ReactNode;

export type FieldRegistry = {
  text: FieldRenderer<TextFieldSchema>;
  number: FieldRenderer<NumberFieldSchema>;
  date: FieldRenderer<DateFieldSchema>;
  select: FieldRenderer<SelectFieldSchema>;
  repeater: FieldRenderer<RepeaterFieldSchema>;
};

Renderer:

function FieldNode({ field }: { field: FieldSchema }) {
  const registry = useFieldRegistry();
  const form = useFormEngine();

  const visible = form.useVisible(field.id);
  const value = form.useFieldValue(field.path);
  const error = form.useFieldError(field.path);
  const meta = form.useFieldMeta(field.path);
  const disabled = form.useDisabled(field.id);
  const required = form.useRequired(field.id);

  if (!visible) return null;

  const Renderer = registry[field.type] as FieldRenderer;

  return (
    <Renderer
      field={field}
      value={value}
      error={error}
      meta={meta}
      disabled={disabled}
      required={required}
      onChange={(next) => form.setFieldValue(field.path, next)}
      onBlur={() => form.markBlurred(field.path)}
    />
  );
}

Perhatikan desain subscription: useFieldValue(path) harus membaca satu path, bukan seluruh values object.


8. Controlled vs Uncontrolled Field Strategy

Dalam form builder, field bisa dikontrol dengan dua cara.

Fully controlled

<input value={String(value ?? '')} onChange={(e) => onChange(e.target.value)} />

Kelebihan:

  • state tunggal di form engine,
  • mudah validasi realtime,
  • mudah observability,
  • mudah conditional logic.

Biaya:

  • setiap keystroke masuk React state,
  • butuh subscription granular,
  • expensive kalau form sangat besar dan engine buruk.

Locally buffered / commit-on-blur

function BufferedTextField({ value, onChange, onBlur }: FieldRendererProps<TextFieldSchema>) {
  const [draft, setDraft] = React.useState(() => String(value ?? ''));

  React.useEffect(() => {
    setDraft(String(value ?? ''));
  }, [value]);

  return (
    <input
      value={draft}
      onChange={(event) => setDraft(event.target.value)}
      onBlur={() => {
        onChange(draft);
        onBlur();
      }}
    />
  );
}

Ini bisa mengurangi global update, tetapi membawa risiko:

  • local draft stale saat server/draft restore mengubah value,
  • conditional visibility tidak bereaksi sampai blur,
  • validation realtime tidak lengkap,
  • reset form harus sync ulang draft.

Decision rule:

Use controlled field when value affects other fields immediately.
Use buffered field only when value is isolated and expensive to update per keystroke.

9. Form State Engine

Untuk form kecil, useReducer cukup. Untuk enterprise form builder besar, engine sebaiknya punya granular subscription.

Minimal shape:

export type FormState = {
  values: Record<string, unknown>;
  meta: Record<string, FieldMeta>;
  errors: Record<string, FieldError[]>;
  submission: SubmissionState;
  schemaVersion: string;
};

export type FieldMeta = {
  touched: boolean;
  dirty: boolean;
  visited: boolean;
};

export type SubmissionState =
  | { tag: 'idle' }
  | { tag: 'submitting'; requestId: string }
  | { tag: 'succeeded'; submissionId: string }
  | { tag: 'failed'; requestId: string; reason: SubmitFailure };

Reducer events:

export type FormEvent =
  | { type: 'FIELD_CHANGED'; path: string; value: unknown }
  | { type: 'FIELD_BLURRED'; path: string }
  | { type: 'FIELD_VISITED'; path: string }
  | { type: 'CLIENT_VALIDATION_COMPLETED'; errors: Record<string, FieldError[]> }
  | { type: 'SERVER_VALIDATION_FAILED'; errors: Record<string, FieldError[]> }
  | { type: 'SUBMIT_STARTED'; requestId: string }
  | { type: 'SUBMIT_SUCCEEDED'; requestId: string; submissionId: string }
  | { type: 'SUBMIT_FAILED'; requestId: string; reason: SubmitFailure }
  | { type: 'DRAFT_RESTORED'; snapshot: FormDraftSnapshot }
  | { type: 'FORM_RESET'; values?: Record<string, unknown> };

Reducer invariant:

function formReducer(state: FormState, event: FormEvent): FormState {
  switch (event.type) {
    case 'FIELD_CHANGED': {
      return {
        ...state,
        values: setPathImmutable(state.values, event.path, event.value),
        meta: {
          ...state.meta,
          [event.path]: {
            ...state.meta[event.path],
            dirty: true,
          },
        },
      };
    }

    case 'SUBMIT_STARTED': {
      if (state.submission.tag === 'submitting') return state;
      return {
        ...state,
        submission: { tag: 'submitting', requestId: event.requestId },
      };
    }

    case 'SUBMIT_SUCCEEDED': {
      if (
        state.submission.tag !== 'submitting' ||
        state.submission.requestId !== event.requestId
      ) {
        return state;
      }

      return {
        ...state,
        submission: { tag: 'succeeded', submissionId: event.submissionId },
      };
    }

    default:
      return state;
  }
}

Request identity guard penting. Tanpa itu, response lama bisa menimpa state baru.


10. Dependency Graph

Conditional visibility dan cross-field validation butuh dependency graph.

Contoh:

const schema: FormSchema = {
  schemaVersion: '2026-07',
  formId: 'risk-assessment',
  title: 'Risk Assessment',
  sections: [
    {
      id: 'business',
      title: 'Business',
      fields: [
        {
          id: 'country',
          path: 'business.country',
          type: 'select',
          label: 'Country',
          options: [
            { value: 'ID', label: 'Indonesia' },
            { value: 'SG', label: 'Singapore' },
          ],
        },
        {
          id: 'taxId',
          path: 'business.taxId',
          type: 'text',
          label: 'Tax ID',
          visibleWhen: { op: 'equals', path: 'business.country', value: 'ID' },
          requiredWhen: { op: 'equals', path: 'business.country', value: 'ID' },
        },
      ],
    },
  ],
};

taxId depends on business.country.

Bangun graph:

export type DependencyGraph = {
  dependentsByPath: Map<string, Set<string>>;
};

export function buildDependencyGraph(schema: FormSchema): DependencyGraph {
  const dependentsByPath = new Map<string, Set<string>>();

  for (const field of walkFields(schema)) {
    const dependencies = collectRulePaths([
      field.visibleWhen,
      field.requiredWhen,
      field.disabledWhen,
      ...(field.validate ?? []).map((rule) => rule.when),
    ]);

    for (const path of dependencies) {
      const set = dependentsByPath.get(path) ?? new Set<string>();
      set.add(field.id);
      dependentsByPath.set(path, set);
    }
  }

  return { dependentsByPath };
}

Saat business.country berubah, kita tidak perlu recompute seluruh form. Kita recompute dependents.

changed path: business.country
affected nodes: taxId, regionalComplianceSection, taxDocumentRepeater

11. Hidden Field Policy

Ini salah satu keputusan paling penting.

Saat field menjadi hidden, apakah value-nya tetap disimpan?

Pilihan:

PolicyBehaviorCocok untuk
Keepvalue tetap adawizard yang field bisa kembali muncul
Clear on hidevalue dihapuscompliance/conditional payload yang tidak boleh mengirim data tidak relevan
Exclude on submitvalue disimpan draft, tetapi tidak dikirimdraft UX + strict submit payload
Archivevalue dipindah ke audit/draft archiveregulated systems

Default enterprise yang aman:

Keep in draft.
Exclude from submit payload when invisible unless schema explicitly says otherwise.
Audit when field with value becomes excluded.

Implementation:

export function buildSubmitPayload(
  schema: FormSchema,
  values: Record<string, unknown>,
  visibility: Record<string, boolean>,
): Record<string, unknown> {
  let payload: Record<string, unknown> = {};

  for (const field of walkFields(schema)) {
    if (!visibility[field.id]) continue;

    const value = getPath(values, field.path);
    payload = setPathImmutable(payload, field.path, value);
  }

  return payload;
}

Jangan submit seluruh values mentah-mentah.


12. Validation Architecture

Validasi punya beberapa layer.

Jangan anggap client validation cukup.

Client validation berguna untuk UX cepat. Server validation adalah authority.

Error model:

export type FieldError = {
  code: string;
  message: string;
  severity: 'error' | 'warning';
  source: 'client' | 'server' | 'business';
};

export type FormError = {
  code: string;
  message: string;
  fieldPaths?: string[];
};

Validation rule:

export type ValidationRule =
  | { kind: 'required'; message?: string; when?: RuleExpr }
  | { kind: 'minLength'; value: number; message?: string; when?: RuleExpr }
  | { kind: 'maxLength'; value: number; message?: string; when?: RuleExpr }
  | { kind: 'regex'; pattern: string; message?: string; when?: RuleExpr }
  | { kind: 'customCode'; code: string; message?: string; when?: RuleExpr };

customCode bukan arbitrary JS. Itu key ke registry validator.

export type ValidatorRegistry = {
  [code: string]: (input: {
    value: unknown;
    values: Record<string, unknown>;
    field: FieldSchema;
  }) => FieldError | null;
};

13. Validation Graph Instead of Validation Soup

Cross-field validation sering menjadi procedural soup:

if (values.a && values.b && values.c !== ...) {
  // ...
}

Lebih baik treat validation as graph.

export type ValidationNode = {
  id: string;
  outputPath?: string;
  dependencies: string[];
  validate: (values: Record<string, unknown>) => FieldError[];
};

Contoh:

const dateRangeValidator: ValidationNode = {
  id: 'date-range',
  outputPath: 'period.endDate',
  dependencies: ['period.startDate', 'period.endDate'],
  validate(values) {
    const start = getPath(values, 'period.startDate');
    const end = getPath(values, 'period.endDate');

    if (!start || !end) return [];
    if (new Date(String(end)) < new Date(String(start))) {
      return [
        {
          code: 'END_BEFORE_START',
          message: 'End date must be after start date.',
          severity: 'error',
          source: 'client',
        },
      ];
    }

    return [];
  },
};

Saat period.startDate berubah, engine tahu validator mana yang perlu jalan.


14. Dynamic Field Arrays / Repeaters

Repeater adalah sumber bug identity.

Jangan key by index.

export type RepeaterItem = {
  itemId: string;
  value: Record<string, unknown>;
};

State shape:

{
  "owners": [
    { "itemId": "tmp_1", "name": "A", "share": 60 },
    { "itemId": "tmp_2", "name": "B", "share": 40 }
  ]
}

Renderer:

function RepeaterField({ field, value, onChange }: FieldRendererProps<RepeaterFieldSchema>) {
  const items = Array.isArray(value) ? value : [];

  return (
    <div>
      {items.map((item, index) => (
        <div key={item.itemId}>
          {field.itemFields.map((childField) => (
            <FieldNode
              key={childField.id}
              field={rewriteFieldPath(childField, `${field.path}.${index}`)}
            />
          ))}

          <button
            type="button"
            onClick={() => onChange(items.filter((_, i) => i !== index))}
          >
            Remove
          </button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => onChange([...items, createEmptyRepeaterItem(field)])}
      >
        Add
      </button>
    </div>
  );
}

Untuk library seperti React Hook Form, useFieldArray menyediakan helper untuk dynamic form arrays dan mendorong identity item yang stabil. Namun prinsipnya tetap sama: identity item harus stabil saat append/remove/reorder.


15. Field-Level Subscription

Jika setiap keystroke membuat seluruh form rerender, form builder akan terasa lambat.

Anti-pattern:

const values = useFormValues();
const value = values[field.path];

Ini subscribe ke semua values.

Better:

const value = useFieldValue(field.path);

Implementasi bisa lewat selector store:

function useFieldValue(path: string) {
  const store = useFormStore();

  return useSyncExternalStoreWithSelector(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot,
    (snapshot) => getPath(snapshot.values, path),
    Object.is,
  );
}

Jika tidak memakai selector store, gunakan component split:

function FormRenderer({ schema }: { schema: FormSchema }) {
  return schema.sections.map((section) => (
    <SectionNode key={section.id} section={section} />
  ));
}

const FieldNode = React.memo(function FieldNode({ field }: { field: FieldSchema }) {
  // subscribe narrowly here
});

16. Draft Persistence

Draft persistence harus versioned.

export type FormDraftSnapshot = {
  formId: string;
  schemaVersion: string;
  savedAt: string;
  values: Record<string, unknown>;
  meta?: Record<string, FieldMeta>;
};

Persistence adapter:

export type DraftAdapter = {
  load(input: { formId: string; userId: string }): Promise<FormDraftSnapshot | null>;
  save(input: { formId: string; userId: string; snapshot: FormDraftSnapshot }): Promise<void>;
  clear(input: { formId: string; userId: string }): Promise<void>;
};

Autosave rules:

Debounce writes.
Do not save invalid transient every keystroke if payload is huge.
Include schemaVersion.
Use migration before restore.
Handle multi-tab conflict.
Never persist secrets unless explicitly allowed.

Migration:

export type DraftMigration = {
  from: string;
  to: string;
  migrate(values: Record<string, unknown>): Record<string, unknown>;
};
export function restoreDraft(
  draft: FormDraftSnapshot,
  currentSchema: FormSchema,
  migrations: DraftMigration[],
): Record<string, unknown> {
  let values = draft.values;
  let version = draft.schemaVersion;

  while (version !== currentSchema.schemaVersion) {
    const migration = migrations.find((m) => m.from === version);
    if (!migration) {
      throw new Error(`No draft migration from ${version} to ${currentSchema.schemaVersion}`);
    }
    values = migration.migrate(values);
    version = migration.to;
  }

  return values;
}

17. Server Validation Contract

Server validation response harus bisa dimap ke field path.

export type SubmitFormResponse =
  | {
      ok: true;
      submissionId: string;
      nextRoute?: string;
    }
  | {
      ok: false;
      kind: 'validation_failed';
      fieldErrors: Record<string, FieldError[]>;
      formErrors: FormError[];
    }
  | {
      ok: false;
      kind: 'conflict';
      message: string;
      latestVersion: string;
    }
  | {
      ok: false;
      kind: 'forbidden';
      message: string;
    };

Submit command:

async function submitForm(input: SubmitInput) {
  const clientErrors = validateForm(input.schema, input.values);

  if (hasBlockingErrors(clientErrors)) {
    input.dispatch({ type: 'CLIENT_VALIDATION_COMPLETED', errors: clientErrors });
    focusFirstError(clientErrors);
    return;
  }

  const requestId = crypto.randomUUID();
  input.dispatch({ type: 'SUBMIT_STARTED', requestId });

  const response = await input.api.submit({
    formId: input.schema.formId,
    schemaVersion: input.schema.schemaVersion,
    payload: buildSubmitPayload(input.schema, input.values, input.visibility),
    idempotencyKey: requestId,
  });

  if (response.ok) {
    input.dispatch({ type: 'SUBMIT_SUCCEEDED', requestId, submissionId: response.submissionId });
    return;
  }

  if (response.kind === 'validation_failed') {
    input.dispatch({ type: 'SERVER_VALIDATION_FAILED', errors: response.fieldErrors });
    focusFirstError(response.fieldErrors);
    return;
  }

  input.dispatch({ type: 'SUBMIT_FAILED', requestId, reason: response });
}

18. React 19 Form Actions Boundary

Untuk form yang submit langsung ke action, useActionState dan useFormStatus bisa menjadi ergonomic boundary.

Namun untuk enterprise form builder, jangan menaruh seluruh logic di JSX action callback.

Good boundary:

function SubmitButton() {
  const status = useFormStatus();

  return (
    <button type="submit" disabled={status.pending}>
      {status.pending ? 'Submitting…' : 'Submit'}
    </button>
  );
}

useFormStatus bagus untuk membaca pending state dari form terdekat. useActionState bagus untuk mengikat hasil action ke state UI.

Tetapi form builder tetap butuh:

schema validation
field registry
draft lifecycle
permission handling
client/server validation mapping
audit/telemetry

React form actions adalah submission primitive, bukan keseluruhan enterprise form engine.


19. Accessibility Contract

Form builder tidak boleh membuat accessibility jadi afterthought.

Setiap field renderer harus memenuhi contract:

export type AccessibleFieldIds = {
  inputId: string;
  labelId: string;
  descriptionId?: string;
  errorId?: string;
};

Text field example:

function TextField({ field, value, error, required, disabled, onChange, onBlur }: FieldRendererProps<TextFieldSchema>) {
  const inputId = React.useId();
  const errorId = `${inputId}-error`;
  const helpId = `${inputId}-help`;

  return (
    <div>
      <label htmlFor={inputId}>
        {field.label} {required ? <span aria-hidden="true">*</span> : null}
      </label>

      {field.helpText ? <p id={helpId}>{field.helpText}</p> : null}

      <input
        id={inputId}
        value={String(value ?? '')}
        required={required}
        disabled={disabled}
        aria-invalid={Boolean(error) || undefined}
        aria-describedby={[field.helpText ? helpId : null, error ? errorId : null]
          .filter(Boolean)
          .join(' ') || undefined}
        onChange={(event) => onChange(event.target.value)}
        onBlur={onBlur}
      />

      {error ? <p id={errorId} role="alert">{error.message}</p> : null}
    </div>
  );
}

Error summary:

function ErrorSummary({ errors }: { errors: Record<string, FieldError[]> }) {
  const items = Object.entries(errors).filter(([, fieldErrors]) => fieldErrors.length > 0);

  if (items.length === 0) return null;

  return (
    <section role="alert" aria-labelledby="form-errors-title" tabIndex={-1}>
      <h2 id="form-errors-title">Please fix the following errors</h2>
      <ul>
        {items.map(([path, fieldErrors]) => (
          <li key={path}>
            <a href={`#${fieldIdFromPath(path)}`}>{fieldErrors[0].message}</a>
          </li>
        ))}
      </ul>
    </section>
  );
}

20. Permission-Aware Fields

Permission tidak hanya menentukan render/hide.

Permission memengaruhi:

visibility
editable/read-only state
submit payload
audit explanation
server command authorization

Decision object:

export type FieldPermissionDecision = {
  visible: boolean;
  editable: boolean;
  reason?: string;
};

Renderer jangan hanya menerima canEdit: boolean. Berikan reason agar UI bisa menjelaskan.

function PermissionAwareField({ field }: { field: FieldSchema }) {
  const decision = useFieldPermission(field.id);

  if (!decision.visible) return null;

  return (
    <FieldNode
      field={field}
      forcedDisabled={!decision.editable}
      disabledReason={decision.reason}
    />
  );
}

Tapi batasnya jelas:

Frontend permission is UX and intent guard.
Server authorization is final authority.

21. Config Versioning and Auditability

Enterprise form builder sering dipakai pada domain yang perlu defensibility.

Submission harus menyimpan:

formId
schemaVersion
rendered field set
visible field set
submitted payload
client validation version
server validation version
user id / actor id
timestamp
correlation id

Audit event:

export type FormAuditEvent =
  | { type: 'FORM_RENDERED'; formId: string; schemaVersion: string; correlationId: string }
  | { type: 'FIELD_CHANGED'; path: string; correlationId: string }
  | { type: 'FIELD_EXCLUDED_FROM_SUBMIT'; path: string; reason: string; correlationId: string }
  | { type: 'CLIENT_VALIDATION_FAILED'; errorCount: number; correlationId: string }
  | { type: 'SERVER_VALIDATION_FAILED'; errorCount: number; correlationId: string }
  | { type: 'FORM_SUBMITTED'; submissionId: string; correlationId: string };

Jangan log raw PII tanpa policy.

Observability harus menjawab:

Schema mana yang user lihat?
Field mana yang hidden saat submit?
Validation mana yang gagal?
Apakah server menolak karena stale schema, permission, atau business rule?
Berapa lama user stuck di section tertentu?

Struktur folder:

features/form-builder/
  model/
    schema.ts
    rules.ts
    validation.ts
    dependency-graph.ts
    form-reducer.ts
    form-store.ts
    draft.ts
    submit.ts
  ui/
    FormRenderer.tsx
    SectionNode.tsx
    FieldNode.tsx
    ErrorSummary.tsx
  fields/
    TextField.tsx
    SelectField.tsx
    DateField.tsx
    RepeaterField.tsx
    registry.ts
  adapters/
    react-hook-form-adapter.ts
    tanstack-query-submit-adapter.ts
    local-draft-adapter.ts
  tests/
    schema-fixtures.ts
    validation.test.ts
    renderer.test.tsx
    submit-flow.test.tsx

Jaga import direction:

ui -> model allowed
fields -> model allowed
model -> ui forbidden
model -> adapters forbidden
adapters -> model allowed

model harus bisa dites tanpa React.


23. Testing Matrix

LayerTest TypeWhat to Prove
Rule evaluatorunitAST evaluation correct
Dependency graphunitaffected nodes calculated correctly
Validation engineunit/propertycross-field rules stable
Reducerunittransitions preserve invariants
Field renderercomponentlabel/error/disabled/accessibility contract
Form rendererintegrationschema renders correct UI
Repeaterintegrationappend/remove/reorder preserves identity
Submit commandintegrationvalidation, pending, server error mapping
Draft migrationunitold draft migrates to new schema
Permission fieldsintegrationhidden/readonly/excluded payload behavior
Accessibilityautomated + manuallabels, descriptions, focus, error summary

Example reducer test:

it('ignores stale submit success', () => {
  const state: FormState = {
    values: {},
    meta: {},
    errors: {},
    schemaVersion: '1',
    submission: { tag: 'submitting', requestId: 'newer' },
  };

  const next = formReducer(state, {
    type: 'SUBMIT_SUCCEEDED',
    requestId: 'older',
    submissionId: 'sub_1',
  });

  expect(next.submission).toEqual({ tag: 'submitting', requestId: 'newer' });
});

Example integration scenario:

Given country is SG
Then Tax ID is not visible
When country changes to ID
Then Tax ID appears and is required
When user submits without Tax ID
Then focus moves to Tax ID error
And submit command is not sent

24. Failure Modes

24.1 Schema becomes an unbounded programming language

Symptoms:

schema has scripts
conditions are impossible to audit
business users can create untestable logic
server cannot validate same rules

Fix:

Use constrained expression AST.
Version expressions.
Validate schema before publish.
Create rule test fixtures.

24.2 Field path is used as identity

path can change when schema changes. id should represent schema node identity.

Fix:

field.id for schema identity
field.path for value address
itemId for dynamic array identity

24.3 Hidden field values are accidentally submitted

Fix:

Build submit payload from visible/resolved schema.
Do not submit raw values object.
Audit excluded fields if necessary.

24.4 Cross-field validation is scattered

Fix:

Move validation into validation graph.
Test validation without React.

24.5 Draft restore breaks after schema change

Fix:

Version every draft.
Require migration or explicit discard path.

24.6 Repeater loses state on reorder

Fix:

Never key by array index.
Preserve item identity.

24.7 Every keystroke rerenders entire form

Fix:

Use field-level subscription.
Split component boundaries.
Profile before memoizing blindly.

24.8 Client permission treated as security

Fix:

Frontend guards improve UX only.
Server authorization remains final.

25. Production Checklist

[ ] Schema has version.
[ ] Schema is validated before use.
[ ] Field id and field path are distinct.
[ ] Field registry has typed renderer contract.
[ ] Dynamic arrays use stable item identity.
[ ] Value state and meta state are separate.
[ ] Derived visibility/requiredness is not stored as source of truth.
[ ] Hidden-field submit policy is explicit.
[ ] Validation is centralized and testable.
[ ] Server validation maps back to field paths.
[ ] Submit command uses request identity/idempotency.
[ ] Draft persistence is versioned and migratable.
[ ] Permission affects UI but server remains authority.
[ ] Accessibility labels/errors/focus are tested.
[ ] Observability records schema version and error taxonomy.
[ ] Form model layer is testable without React.

26. The Real Lesson

Enterprise form builder is not about making forms dynamic.

It is about separating declarative structure, state ownership, validation, visibility, permission, persistence, and command lifecycle so that form complexity can grow without turning every field into a bespoke component and every submit handler into a procedural trap.

The invariant to keep:

A field component renders and reports intent.
The form engine owns value/meta/errors.
The rules engine derives visibility/requiredness/validation.
The submit command owns server interaction.
The schema is versioned evidence of what the user was asked to provide.

If you preserve that split, form builder complexity becomes manageable.

Lesson Recap

You just completed lesson 115 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.