Vivek Jalondhara. All writing

Frontend

Migrating Saved React Form Schemas Without Losing Meaning

By Vivek Jalondhara · 4 min read
Published

Evolve a configurable form format while preserving field identity, old submissions, editor drafts, and a recoverable save path.

A form builder stores decisions that can outlive the component code that created them. Moving a required flag into a validation object seems small until an older form opens with different defaults or previously accepted submissions become impossible to review.

This illustrative SmackDab CRM scenario upgrades a saved form configuration from one format to another. It focuses on compatibility and editing behavior rather than the general schema-driven rendering pattern. The migration below is educational code, not a description of a private CRM migration.

Separate format versions from editing revisions

A format version describes how to interpret the document. An editing revision identifies a particular saved state of that document. Incrementing one does not replace the other. Two users can edit the same version-two form and still overwrite each other unless the save operation checks the expected revision.

Also distinguish configuration from submissions. Renaming a field label should not rename the storage key used by previous answers. Give each field a stable identifier and preserve it through migrations, duplication rules, layout moves, and conditional references.

Validate before transforming

Treat stored JSON as unknown input, even when the application originally wrote it. Select a validator from the version discriminator, reject unsupported versions, and validate the complete old shape before running a migration. A TypeScript union helps narrow already validated values; it cannot establish that an arbitrary database document matches the union.

Validate the target shape afterward as well. Structural validation catches missing properties, while semantic checks catch duplicate field identifiers, dangling visibility references, and rules that refer to removed options. Keep both checks outside the renderer so preview and submission paths share the same interpretation.

Make defaults a deliberate conversion

In this example, version one omitted required when a field was optional. Version two always stores rules.required. The migration therefore writes false for the missing old flag and preserves explicit true or false values. That decision documents the historical meaning rather than borrowing whatever default a new component happens to use.

JSON Schema’s default annotation does not itself populate missing data during validation. If a form generator or validation library inserts defaults, that is additional behavior to configure and verify. Migration logic should not silently depend on it.

Keep loading separate from saving

For a small transition, migrate the loaded form in memory and render the current shape. Preserve the original stored document until a deliberate save succeeds. This lets the editor recover from an unsupported component or failed validation without destroying the only readable copy.

A background migration can be preferable when every consumer needs the current format immediately. That choice introduces batching, partial completion, and rollback concerns. In either approach, reject writes from incompatible older clients and use a revision check so migration does not overwrite another editor’s changes.

Preserve a draft through renderer changes

React associates component state with identity and position. Changing a field’s key during migration can remount its input and discard local state. Use the stable field identifier as the key, and keep labels and visual positions out of that identity.

An open editor needs its own rule for a remote schema update. Replacing a dirty draft whenever a new configuration arrives is surprising. Offer a reload or merge decision, and retain the draft while resolving it. A format migration cannot decide whether a user intended to keep an unsaved field value.

Test compatibility using old documents

Keep small fixtures from each supported format, including omitted optional values, explicit false values, and fields referenced by conditions. Verify that migration leaves its input unchanged, preserves identifiers and order, and produces the same current document when invoked again. Separately test unknown versions and malformed shapes at the validation boundary.

Finally, render a migrated form and review an older submission against its recorded form definition or revision. Successfully opening the latest editor does not prove that historical answers still have the same meaning.

Preserve the meaning of an omitted required flag

This pure migration accepts only previously validated documents. Runtime validators must reject unknown versions and invalid fields before this function is called.

Illustrative example · TypeScript · Simplified for explanation

type FormV1 = {
  version: 1;
  fields: { id: string; label: string; required?: boolean }[];
};
type FormV2 = {
  version: 2;
  fields: { id: string; label: string; rules: { required: boolean } }[];
};

function migrateForm(saved: FormV1 | FormV2): FormV2 {
  if (saved.version === 2) return saved;
  return {
    version: 2,
    fields: saved.fields.map(field => ({
      id: field.id,
      label: field.label,
      rules: { required: field.required ?? false },
    })),
  };
}
How the example fits together
  1. Validate the saved version
  2. Apply a deterministic migration
  3. Validate references and target shape
  4. Render a draft and save with a revision check

Conceptual flow for the example above.

Tradeoff to keep in mind

Migrating on read keeps deployment small but leaves old formats in storage. Supporting several formats requires explicit compatibility boundaries for editors, public forms, and historical submissions.

Related project: SmackDab CRM →

References & further reading

Related posts