Skip to content

Svelte Integration

This example demonstrates a complete form implementation using EncolaJS Form Controller with Svelte 5. The example uses Svelte 5's new runes API ($state, $derived, $effect) to integrate the FormController with Svelte's reactivity system.

Svelte 5 Runes Integration

The Svelte integration uses Svelte 5's runes API to bridge between the FormController's signal-based reactivity and Svelte's reactive state. This approach provides a clean, declarative API that feels natural in Svelte applications.

svelte
<script>
import { onMount, onDestroy } from 'svelte'
import createForm, { FormController, PlainObjectDataSource } from '../../../../src/'
import { createEncolaValidatorFromRules } from '../../../../encola'
import { ValidatorFactory } from '@encolajs/validator'
import { useFormController } from './useFormController.svelte.js'
import { useField } from './useField.svelte.js'
import { useArrayField } from './useArrayField.svelte.js'

// Create validator factory
const validatorFactory = new ValidatorFactory()

// Initial form values
const initialValues = {
  name: '',
  email: '',
  age: 18,
  password: '',
  confirmPassword: '',
  profile: {
    bio: '',
    website: ''
  },
  preferences: {
    newsletter: false,
    notifications: true
  },
  contacts: []
}

// Validation rules
const rules = {
  'name': 'required|min_length:2|max_length:50',
  'email': 'required|email',
  'age': 'required|integer|gte:18|lte:120',
  'password': 'required|min_length:8|matches:^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])',
  'confirmPassword': 'required|same_as:@password',
  'profile.bio': 'max_length:500',
  'profile.website': 'url',
  'contacts': 'array_min:2',
  'contacts.*.name': 'required',
  'contacts.*.email': 'required|email'
}

// Custom validation messages
const messages = {
  'password:matches': 'Password must contain at least one digit, one small letter and one capital letter',
  'contacts.*.name:required': 'Contact name is required',
  'contacts.*.email:required': 'Contact email is required',
}

// Create data source and validator
const dataSource = new PlainObjectDataSource(initialValues)
const validator = createEncolaValidatorFromRules(validatorFactory, rules, messages)
const formController = createForm(dataSource, validator)

// Create form state
const form = useFormController(formController)

// Default item for contacts array
const contactDefault = { name: '', email: '' }
const contacts = useArrayField(formController, 'contacts', contactDefault)

// Helper to get input value based on type
const getInputValue = (event) => {
  const element = event.target
  if (element.type === 'checkbox') {
    return element.checked
  } else if (element.type === 'number') {
    return element.value === '' ? undefined : Number(element.value)
  }
  return element.value
}

// Handle form submission
const handleSubmit = async (e) => {
  e.preventDefault()
  const success = await form.methods.submit()
  if (success) {
    alert('Form submitted successfully!')
    console.log('Form data:', form.methods.getValue())
  } else {
    alert('Please fix the errors before submitting')
  }
}

// Handle form reset
const handleReset = () => {
  form.methods.reset()
}

// Cleanup on destroy
onDestroy(() => {
  form.cleanup()
  contacts.cleanup()
})
</script>

<div class="bg-gray-100 min-h-screen p-4">
  <div class="mb-8">
    <div class="mb-8">
      <h1 class="text-3xl font-bold text-gray-900 mb-2">User Registration Form</h1>
      <p class="text-gray-600">Complete form with EncolaJS Validator and Svelte 5 runes</p>

      <!-- Form State Indicators -->
      <div class="mt-4 flex gap-4 text-sm">
        <span>Status: <span class={form.state.isDirty ? 'text-orange-600 font-medium' : 'text-gray-500'}>
          {form.state.isDirty ? 'Has Changes' : 'No Changes'}
        </span></span>
        <span>Touched: <span class="font-medium">{form.state.isTouched ? 'Touched' : 'Untouched'}</span></span>
        <span>Valid: <span class={form.state.isValid ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
          {form.state.isValid ? 'Valid' : 'Invalid'}
        </span></span>
      </div>
    </div>

    <form class="space-y-8" on:submit={handleSubmit}>
      <!-- Basic Information -->
      <section>
        <h2 class="text-xl font-semibold text-gray-900 mb-4">Basic Information</h2>
        <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
          <!-- Name Field -->
          {#snippet nameField()}
            {@const field = useField(formController, 'name')}
            <div>
              <label for="name" class="block text-sm font-medium text-gray-700">Full Name</label>
              <input
                type="text"
                id="name"
                value={field.value}
                on:input={(e) => {
                  const val = getInputValue(e)
                  field.handleInput(val)
                  field.handleChange(val)
                }}
                class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                placeholder="Enter your full name"
              />
              {#if field.hasErrors}
                <div class="text-red-500 text-sm mt-1">
                  {field.errors[0]}
                </div>
              {/if}
            </div>
          {/snippet}
          {@render nameField()}

          <!-- Email Field -->
          {#snippet emailField()}
            {@const field = useField(formController, 'email')}
            <div>
              <label for="email" class="block text-sm font-medium text-gray-700">Email Address</label>
              <input
                type="email"
                id="email"
                value={field.value}
                on:input={(e) => {
                  const val = getInputValue(e)
                  field.handleInput(val)
                  field.handleChange(val)
                }}
                class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                placeholder="Enter your email"
              />
              {#if field.hasErrors}
                <div class="text-red-500 text-sm mt-1">
                  {field.errors[0]}
                </div>
              {/if}
            </div>
          {/snippet}
          {@render emailField()}

          <!-- Age Field -->
          {#snippet ageField()}
            {@const field = useField(formController, 'age')}
            <div>
              <label for="age" class="block text-sm font-medium text-gray-700">Age</label>
              <input
                type="number"
                id="age"
                value={field.value}
                on:input={(e) => {
                  const val = getInputValue(e)
                  field.handleInput(val)
                  field.handleChange(val)
                }}
                min="18"
                max="120"
                class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
              />
              {#if field.hasErrors}
                <div class="text-red-500 text-sm mt-1">
                  {field.errors[0]}
                </div>
              {/if}
            </div>
          {/snippet}
          {@render ageField()}
        </div>
      </section>

      <!-- Security -->
      <section>
        <h2 class="text-xl font-semibold text-gray-900 mb-4">Security</h2>
        <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
          <!-- Password Field -->
          {#snippet passwordField()}
            {@const field = useField(formController, 'password')}
            <div>
              <label for="password" class="block text-sm font-medium text-gray-700">Password</label>
              <input
                type="password"
                id="password"
                value={field.value}
                on:input={(e) => {
                  const val = getInputValue(e)
                  field.handleInput(val)
                  field.handleChange(val)
                }}
                class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                placeholder="Create a secure password"
              />
              {#if field.hasErrors}
                <div class="text-red-500 text-sm mt-1">
                  {field.errors[0]}
                </div>
              {/if}
            </div>
          {/snippet}
          {@render passwordField()}

          <!-- Confirm Password Field -->
          {#snippet confirmPasswordField()}
            {@const field = useField(formController, 'confirmPassword')}
            <div>
              <label for="confirmPassword" class="block text-sm font-medium text-gray-700">Confirm Password</label>
              <input
                type="password"
                id="confirmPassword"
                value={field.value}
                on:input={(e) => {
                  const val = getInputValue(e)
                  field.handleInput(val)
                  field.handleChange(val)
                }}
                class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                placeholder="Confirm your password"
              />
              {#if field.hasErrors}
                <div class="text-red-500 text-sm mt-1">
                  {field.errors[0]}
                </div>
              {/if}
            </div>
          {/snippet}
          {@render confirmPasswordField()}
        </div>
      </section>

      <!-- Profile Information -->
      <section>
        <h2 class="text-xl font-semibold text-gray-900 mb-4">Profile Information</h2>
        <div class="space-y-4">
          <!-- Bio Field -->
          {#snippet bioField()}
            {@const field = useField(formController, 'profile.bio')}
            <div>
              <label for="bio" class="block text-sm font-medium text-gray-700">Bio</label>
              <textarea
                id="bio"
                value={field.value}
                on:input={(e) => {
                  const val = getInputValue(e)
                  field.handleInput(val)
                  field.handleChange(val)
                }}
                rows="3"
                class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                placeholder="Tell us about yourself (optional)"
              ></textarea>
              {#if field.hasErrors}
                <div class="text-red-500 text-sm mt-1">
                  {field.errors[0]}
                </div>
              {/if}
            </div>
          {/snippet}
          {@render bioField()}

          <!-- Website Field -->
          {#snippet websiteField()}
            {@const field = useField(formController, 'profile.website')}
            <div>
              <label for="website" class="block text-sm font-medium text-gray-700">Website</label>
              <input
                type="url"
                id="website"
                value={field.value}
                on:input={(e) => {
                  const val = getInputValue(e)
                  field.handleInput(val)
                  field.handleChange(val)
                }}
                class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                placeholder="https://yourwebsite.com (optional)"
              />
              {#if field.hasErrors}
                <div class="text-red-500 text-sm mt-1">
                  {field.errors[0]}
                </div>
              {/if}
            </div>
          {/snippet}
          {@render websiteField()}
        </div>
      </section>

      <!-- Preferences -->
      <section>
        <h2 class="text-xl font-semibold text-gray-900 mb-4">Preferences</h2>
        <div class="space-y-4">
          <!-- Newsletter Checkbox -->
          {#snippet newsletterField()}
            {@const field = useField(formController, 'preferences.newsletter')}
            <div class="flex items-center">
              <input
                type="checkbox"
                id="newsletter"
                checked={field.value}
                on:change={(e) => field.handleChange(getInputValue(e))}
                class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
              />
              <label for="newsletter" class="ml-2 block text-sm text-gray-900">
                Subscribe to newsletter
              </label>
            </div>
          {/snippet}
          {@render newsletterField()}

          <!-- Notifications Checkbox -->
          {#snippet notificationsField()}
            {@const field = useField(formController, 'preferences.notifications')}
            <div class="flex items-center">
              <input
                type="checkbox"
                id="notifications"
                checked={field.value}
                on:change={(e) => field.handleChange(getInputValue(e))}
                class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
              />
              <label for="notifications" class="ml-2 block text-sm text-gray-900">
                Enable notifications
              </label>
            </div>
          {/snippet}
          {@render notificationsField()}
        </div>
      </section>

      <!-- Contacts Array -->
      <section>
        <div>
          <h2 class="mb-2 flex items-center justify-between">
            <div class="text-xl font-semibold text-gray-900" style="margin-top: 0">Emergency Contacts</div>
            <button
              type="button"
              on:click={contacts.arrayAppend}
              class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm"
            >
              Add Contact
            </button>
          </h2>
          {#if contacts.errors[0]}
            <div class="text-red-500 text-sm mb-4">
              {contacts.errors[0]}
            </div>
          {/if}
          <div class="space-y-4">
            {#each contacts.items as contact, index (index)}
              <div class="border border-gray-200 rounded-lg p-4 space-y-4">
                <div class="flex justify-between items-center">
                  <h4 class="font-medium text-gray-900">Contact {index + 1}</h4>
                  <div class="flex gap-2">
                    <button
                      type="button"
                      on:click={() => contacts.arrayMoveUp(index)}
                      disabled={index === 0}
                      class="text-blue-600 hover:text-blue-800 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
                    >↑</button>
                    <button
                      type="button"
                      on:click={() => contacts.arrayMoveDown(index)}
                      disabled={index === contacts.items.length - 1}
                      class="text-blue-600 hover:text-blue-800 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
                    >↓</button>
                    <button
                      type="button"
                      on:click={() => contacts.arrayRemove(index)}
                      class="text-red-600 hover:text-red-800 text-sm"
                    >Remove</button>
                  </div>
                </div>

                <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                  <!-- Contact Name -->
                  {#snippet contactNameField()}
                    {@const field = useField(formController, `contacts.${index}.name`)}
                    <div>
                      <label class="block text-sm font-medium text-gray-700">Name</label>
                      <input
                        type="text"
                        value={field.value}
                        on:input={(e) => {
                          const val = getInputValue(e)
                          field.handleInput(val)
                          field.handleChange(val)
                        }}
                        class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                      />
                      {#if field.hasErrors}
                        <div class="text-red-500 text-sm mt-1">
                          {field.errors[0]}
                        </div>
                      {/if}
                    </div>
                  {/snippet}
                  {@render contactNameField()}

                  <!-- Contact Email -->
                  {#snippet contactEmailField()}
                    {@const field = useField(formController, `contacts.${index}.email`)}
                    <div>
                      <label class="block text-sm font-medium text-gray-700">Email</label>
                      <input
                        type="email"
                        value={field.value}
                        on:input={(e) => {
                          const val = getInputValue(e)
                          field.handleInput(val)
                          field.handleChange(val)
                        }}
                        class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                      />
                      {#if field.hasErrors}
                        <div class="text-red-500 text-sm mt-1">
                          {field.errors[0]}
                        </div>
                      {/if}
                    </div>
                  {/snippet}
                  {@render contactEmailField()}
                </div>
              </div>
            {/each}
          </div>
        </div>
      </section>

      <!-- Form Actions -->
      <div class="flex justify-between pt-6 border-t border-gray-200">
        <button
          type="button"
          on:click={handleReset}
          class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-md"
        >
          Reset Form
        </button>

        <button
          type="submit"
          class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md"
        >
          Create Account
        </button>
      </div>
    </form>
  </div>
</div>
js
import { effect } from 'alien-signals'

/**
 * Creates form controller state using Svelte 5 runes
 * @param {IFormController} formController - The form controller instance
 * @returns {Object} Form state and methods
 */
export function useFormController(formController) {
  // Create reactive state using $state rune
  let state = $state({
    isDirty: formController.isDirty(),
    isTouched: formController.isTouched(),
    isValid: formController.isValid(),
    errors: formController.getErrors(),
    values: formController.getValues()
  })

  // Set up effects for reactivity
  const effects = []

  // Form-level state reactivity
  effects.push(effect(() => {
    state.isDirty = formController.isDirty()
    state.isTouched = formController.isTouched()
    state.isValid = formController.isValid()
  }))

  // Error reactivity
  effects.push(effect(() => {
    formController.errorsChanged()
    state.errors = { ...formController.getErrors() }
  }))

  // Methods
  const methods = {
    submit: async () => {
      return await formController.submit()
    },
    reset: () => {
      formController.reset()
    },
    setValue: (path, value, options) => {
      return formController.setValue(path, value, options)
    },
    getValue: (path) => {
      return formController.getValue(path)
    },
    getErrors: (path) => {
      if (path) {
        return state.errors[path] || []
      }
      return state.errors
    },
    hasErrors: (path) => {
      const errors = state.errors[path] || []
      return errors.length > 0
    }
  }

  // Cleanup function
  const cleanup = () => {
    effects.forEach(dispose => dispose?.())
  }

  return {
    state,
    methods,
    controller: formController,
    cleanup
  }
}
js
import { effect } from 'alien-signals'

/**
 * Creates field state using Svelte 5 runes
 * @param {IFormController} formController - The form controller instance
 * @param {string} fieldPath - The path to the field
 * @returns {Object} Field state and handlers
 */
export function useField(formController, fieldPath) {
  // Create reactive state using $state rune
  let state = $state({
    value: formController.getValue(fieldPath),
    errors: formController.getErrors()[fieldPath] || [],
    hasErrors: (formController.getErrors()[fieldPath] || []).length > 0
  })

  const field = formController.field(fieldPath)
  const effects = []

  // Watch for changes to this specific field only
  effects.push(effect(() => {
    field.valueUpdated() // Subscribe to field-specific value changes
    state.value = formController.getValue(fieldPath)
  }))

  // Watch for errors on this specific field
  effects.push(effect(() => {
    formController.errorsChanged() // Subscribe to errors changes
    const errors = formController.getErrors()[fieldPath] || []
    state.errors = errors
    state.hasErrors = errors.length > 0
  }))

  // Handlers
  const handleInput = (inputValue) => {
    formController.setValue(fieldPath, inputValue, {
      touched: true,
      dirty: false
    })
  }

  const handleChange = (inputValue) => {
    formController.setValue(fieldPath, inputValue, {
      touched: true,
      dirty: true
    })
  }

  // Cleanup function
  const cleanup = () => {
    effects.forEach(dispose => dispose?.())
  }

  return {
    get value() { return state.value },
    get errors() { return state.errors },
    get hasErrors() { return state.hasErrors },
    handleInput,
    handleChange,
    cleanup
  }
}
js
import { effect } from 'alien-signals'

/**
 * Creates array field state using Svelte 5 runes
 * @param {IFormController} formController - The form controller instance
 * @param {string} fieldPath - The path to the array field
 * @param {Object} defaultItem - Default item to add when appending
 * @returns {Object} Array field state and methods
 */
export function useArrayField(formController, fieldPath, defaultItem = {}) {
  const value = formController.getValue(fieldPath)

  // Create reactive state using $state rune
  let state = $state({
    items: Array.isArray(value) ? value : [],
    errors: formController.getErrors()[fieldPath] || []
  })

  const field = formController.field(fieldPath)
  const effects = []

  // Watch for changes to this specific array field only
  effects.push(effect(() => {
    field.valueUpdated() // Subscribe to field-specific value changes
    const value = formController.getValue(fieldPath)
    state.items = Array.isArray(value) ? value : []
  }))

  // Watch for errors on this specific field
  effects.push(effect(() => {
    formController.errorsChanged()
    state.errors = formController.getErrors()[fieldPath] || []
  }))

  // Array manipulation methods
  const arrayAppend = () => {
    const newItem = { ...defaultItem }
    formController.arrayAppend(fieldPath, newItem).catch(console.error)
  }

  const arrayRemove = (index) => {
    formController.arrayRemove(fieldPath, index).catch(console.error)
  }

  const arrayMoveUp = (index) => {
    if (index > 0) {
      formController.arrayMove(fieldPath, index, index - 1).catch(console.error)
    }
  }

  const arrayMoveDown = (index) => {
    if (index < state.items.length - 1) {
      formController.arrayMove(fieldPath, index, index + 1).catch(console.error)
    }
  }

  // Cleanup function
  const cleanup = () => {
    effects.forEach(dispose => dispose?.())
  }

  return {
    get items() { return state.items },
    get errors() { return state.errors },
    arrayAppend,
    arrayRemove,
    arrayMoveUp,
    arrayMoveDown,
    cleanup
  }
}

Key Concepts

Svelte 5 Runes

Svelte 5 introduces runes - a new API for declaring reactive state and effects. The integration uses:

  • $state - Creates reactive state that automatically updates the UI when changed
  • $derived - Creates derived values that automatically recompute when dependencies change
  • Getters - Used to expose reactive state from composable functions

Reactivity with alien-signals

The integration uses alien-signals effects to bridge between the FormController's signal-based reactivity and Svelte's state management. When signals change, the $state is updated, triggering Svelte's reactivity.

Field-specific Change Tracking: Each field has its own valueUpdated() method that returns an incrementing number when the field's value changes. Field composables subscribe only to their specific field's changes using field.valueUpdated(), preventing unnecessary re-renders when unrelated fields change. This is much more efficient than watching a global data change signal.

Composable Functions

The integration provides three main composable functions:

  • useFormController() - Creates form-level state, subscribes to form changes, exposes form methods
  • useField() - Manages individual field state, subscribes to field-specific changes, provides input handlers
  • useArrayField() - Manages array fields, subscribes to array changes, exposes array manipulation methods

Snippets for Field Isolation

Svelte 5 snippets are used to create isolated scopes for each field. This ensures that each field's useField() call creates its own reactive state and effects, preventing interference between fields.

svelte
{#snippet nameField()}
  {@const field = useField(formController, 'name')}
  <input value={field.value} on:input={(e) => field.handleChange(e.target.value)} />
{/snippet}
{@render nameField()}

Cleanup

All composable functions return a cleanup() method that should be called in Svelte's onDestroy() lifecycle hook to properly dispose of alien-signals effects and prevent memory leaks.

javascript
const form = useFormController(formController)

onDestroy(() => {
  form.cleanup()
})

Benefits of This Approach

  1. Modern Svelte API - Uses Svelte 5's new runes API for cleaner, more intuitive code
  2. Optimized Reactivity - Field-specific subscriptions prevent unnecessary updates
  3. Composable - Functions can be composed to build complex forms
  4. Type Safety - Works seamlessly with TypeScript for type-safe form handling
  5. Clean Syntax - Svelte's template syntax combined with runes provides very readable code
  6. Reusable Logic - Composables can be reused across different projects and components

MIT Licensed