Skip to main content

Form Builder Internals

The form builder uses Zustand for state management and a registry pattern for field rendering.

Architecture Overview

Zustand Store

// src/stores/form-builder-store.ts

interface FormBuilderState {
  // Schema state
  schema: FormSchema;
  selectedFieldId: string | null;
  selectedPageId: string | null;

  // Dirty tracking
  isDirty: boolean;
  lastSavedAt: Date | null;

  // Actions
  setSchema: (schema: FormSchema) => void;
  addField: (pageId: string, field: FormElement) => void;
  updateField: (fieldId: string, updates: Partial<FormElement>) => void;
  removeField: (fieldId: string) => void;
  moveField: (fieldId: string, newIndex: number) => void;
  selectField: (fieldId: string | null) => void;
}

export const useFormBuilder = create<FormBuilderState>((set, get) => ({
  schema: initialSchema,
  selectedFieldId: null,
  isDirty: false,

  addField: (pageId, field) => {
    set((state) => ({
      schema: addFieldToPage(state.schema, pageId, field),
      isDirty: true,
    }));
  },

  updateField: (fieldId, updates) => {
    set((state) => ({
      schema: updateFieldInSchema(state.schema, fieldId, updates),
      isDirty: true,
    }));
  },
  // ...
}));

Field Registry

Instead of a giant switch statement, we use a registry:
// src/components/engine/registry.tsx

import { TextFieldRenderer } from "./fields/text-field";
import { ChoiceFieldRenderer } from "./fields/choice-field";
import { AddressFieldRenderer } from "./fields/address-field";
// ...

export const fieldRenderers: Record<string, React.ComponentType<FieldProps>> = {
  text: TextFieldRenderer,
  number: NumberFieldRenderer,
  date: DateFieldRenderer,
  choice: ChoiceFieldRenderer,
  address: AddressFieldRenderer,
  files: FilesFieldRenderer,
  group: GroupFieldRenderer,
  repeated: RepeatedFieldRenderer,
  // ... all field types
};

export function renderField(element: FormElement, props: FieldProps) {
  const Renderer = fieldRenderers[element.type];
  if (!Renderer) {
    console.warn(`Unknown field type: ${element.type}`);
    return null;
  }
  return <Renderer element={element} {...props} />;
}

Adding a New Field Type

  1. Define the schema in src/types/schema.ts
  2. Create the renderer in src/components/engine/fields/
  3. Add to registry in src/components/engine/registry.tsx
  4. Add toolbox entry in src/components/form-builder/toolbox.tsx
  5. Add properties panel in src/components/form-builder/properties-panel.tsx
Example: Rating field
// 1. Schema
export const RatingFieldSchema = BaseElementSchema.extend({
  type: z.literal("rating"),
  maxStars: z.number().default(5),
});

// 2. Renderer
export function RatingFieldRenderer({ element, value, onChange }) {
  return (
    <div className="flex gap-1">
      {Array.from({ length: element.maxStars }).map((_, i) => (
        <button key={i} onClick={() => onChange(i + 1)}>
          {i < value ? "★" : "☆"}
        </button>
      ))}
    </div>
  );
}

// 3. Registry
fieldRenderers.rating = RatingFieldRenderer;