Skip to content

Best Practices

Guidelines for designing effective, maintainable forms with the Vyuh form system.

Form Design

Keep Forms Focused

Each form should have a single purpose. If a form grows beyond 15-20 fields, consider splitting it into a StepForm with logical steps.

dart
// Instead of one massive form, use steps
StepForm(
  title: 'Student Registration',
  steps: [
    FormBuilder(title: 'Personal Info').text(...).build(),
    FormBuilder(title: 'Academic Background').select(...).build(),
    FormBuilder(title: 'Course Selection').reference(...).build(),
  ],
)

Use Sections for Visual Grouping

Group related fields into sections with descriptive titles:

dart
.section('Contact Information', (s) => s
  .text('email', title: 'Email')
  .phone('phone', title: 'Phone'),
  description: 'How we can reach you',
)

Leverage the 12-Column Grid

Use column spans intentionally. Related fields of similar importance should share a row:

dart
// Good: related fields side-by-side
.text('first_name', title: 'First Name')
.text('last_name', title: 'Last Name')

// Good: address in a natural layout
.text('street', title: 'Street Address')  // full width
.text('city', title: 'City')
.select('state', title: 'State')
.text('zip', title: 'ZIP Code')

Naming Conventions

Field Names

Use snake_case for field names. Names must be unique across the entire form (including inside sections):

dart
// Good
.text('student_name', title: 'Student Name')
.text('emergency_contact_phone', title: 'Emergency Phone')

// Avoid: camelCase (inconsistent with JSON conventions)
.text('studentName', title: 'Student Name')

Consistent Titles

Titles should be concise, user-facing labels:

dart
// Good
.text('email', title: 'Email Address')
.dateTime('start_date', title: 'Start Date')

// Avoid: developer jargon
.text('email', title: 'Email String Value')

Validation

Order Matters

Validations run in order. Put the most common failure first:

dart
// Good: required check first, then format
.text('email', title: 'Email')
  .required('Email is required')
  .email('Enter a valid email')

// Less good: format check on empty field wastes time
.text('email', title: 'Email')
  .email('Enter a valid email')
  .required('Email is required')

Helpful Error Messages

Write error messages that tell the user what to do, not just what went wrong:

dart
// Good: actionable
.required('Please enter your email address')
.email('Please enter a valid email (e.g., name@example.com)')
.minLength(8, message: 'Password must be at least 8 characters')

// Less helpful: just states the rule
.required('Required')
.email('Invalid email')
.minLength(8)

Use Soft Validation for Recommendations

When a value is unusual but not invalid, use soft validation:

dart
NumberField(
  name: 'class_size',
  title: 'Class Size',
  validations: [
    NumberRangeValidation(min: 1, max: 500, errorMessage: 'Must be 1-500'),
    SoftRangeValidation(softMin: 10, softMax: 40,
      warningMessage: 'Recommended range is 10-40'),
  ],
)

Conditional Logic

Prefer Rules over Manual State Management

Use the rules system instead of managing visibility/enabled state manually:

dart
// Good: declarative rule
.text('payment_info', title: 'Payment Info')
  .showWhen(When.fieldEquals('type', 'paid'))

// Avoid: imperative state management
// if (typeController.value == 'paid') showPaymentField();

Keep Conditions Simple

Complex conditions should be broken into named intermediate steps when possible. Use CompoundCondition sparingly:

dart
// Acceptable: clear AND condition
.showWhen(When.allOf([
  When.fieldEquals('type', 'paid'),
  When.fieldTrue('is_international'),
]))

// Consider simplifying if logic gets deeper than 2 levels

Performance

Dispose Forms

Always dispose forms when the widget is removed:

dart
@override
void dispose() {
  form.dispose();
  super.dispose();
}

Avoid Unnecessary Rebuilds

Use MobX Observer widgets to isolate rebuilds to the parts of the UI that actually change:

dart
Observer(
  builder: (_) => ElevatedButton(
    onPressed: form.valid.value ? _submit : null,
    child: Text('Submit'),
  ),
)

Pre-populate at Construction Time

Set initial values during build() rather than after construction to avoid unnecessary re-validation:

dart
// Good: values applied before listeners are wired
final form = FormBuilder(title: 'Edit').text(...).build(
  initialValues: {'name': 'Alice'},
);

// Less efficient: triggers re-validation after construction
final form = FormBuilder(title: 'Edit').text(...).build();
form.initialValues = {'name': 'Alice'};

Form Editor

Validate Before Export

Always validate the form definition before exporting:

dart
final errors = store.validate();
if (errors.isNotEmpty) {
  // Show validation errors to the designer
  return;
}
final json = store.toJson();

Use Descriptors for Configuration

Prefer FormEditorDescriptor.withDefaults() over manual registry construction:

dart
// Good: declarative, composable
final store = FormEditorStore.fromDescriptors([
  FormEditorDescriptor.withDefaults(
    fieldTypeDescriptors: [MyCustomFieldDescriptor()],
  ),
]);

// More verbose: manual registry
final store = FormEditorStore.withRegistry(
  registry: FormEditorRegistry(
    fieldTypes: [...allDefaults, ...custom],
    // Must remember all defaults
  ),
);

Next Steps