Skip to content

React Integration

This example demonstrates a complete form implementation using EncolaJS Form Controller with React. The example uses custom React hooks to integrate the FormController with React's component lifecycle and state management.

React Hooks Architecture

The React integration uses custom hooks that bridge between the FormController's signal-based reactivity and React's state management. This approach provides a clean, declarative API that feels natural in React applications.

jsx
import React, { useMemo } from 'react'
import createForm, { FormController, PlainObjectDataSource } from '../../../../src/'
import { createEncolaValidatorFromRules } from '../../../../encola'
import { ValidatorFactory } from '@encolajs/validator'
import { useFormController } from './useFormController.jsx'
import { useField } from './useField.jsx'
import { useArrayField } from './useArrayField.jsx'

// Input helper
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
}

// Field Component
function Field({ controller, name, label, type = 'text', placeholder, rows, min, max }) {
  const { value, errors, hasErrors, handleInput, handleChange } = useField(controller, name)

  const InputComponent = type === 'textarea' ? 'textarea' : 'input'

  return (
    <div>
      <label htmlFor={name} className="block text-sm font-medium text-gray-700">
        {label}
      </label>
      <InputComponent
        type={type !== 'textarea' ? type : undefined}
        id={name}
        value={value || ''}
        checked={type === 'checkbox' ? value : undefined}
        onChange={(e) => {
          const val = getInputValue(e)
          handleInput(val)
          handleChange(val)
        }}
        rows={rows}
        min={min}
        max={max}
        className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
        placeholder={placeholder}
      />
      {hasErrors && (
        <div className="text-red-500 text-sm mt-1">
          {errors[0]}
        </div>
      )}
    </div>
  )
}

// Checkbox Component
function Checkbox({ controller, name, label }) {
  const { value, handleChange } = useField(controller, name)

  return (
    <div className="flex items-center">
      <input
        type="checkbox"
        id={name}
        checked={value || false}
        onChange={(e) => handleChange(getInputValue(e))}
        className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
      />
      <label htmlFor={name} className="ml-2 block text-sm text-gray-900">
        {label}
      </label>
    </div>
  )
}

// Contact Array Component
function ContactsArray({ controller }) {
  const contactDefault = { name: '', email: '' }
  const { items, errors, arrayAppend, arrayRemove, arrayMoveUp, arrayMoveDown } = useArrayField(
    controller,
    'contacts',
    contactDefault
  )

  return (
    <div>
      <h2 className="mb-2 flex items-center justify-between">
        <div className="text-xl font-semibold text-gray-900" style={{ marginTop: 0 }}>
          Emergency Contacts
        </div>
        <button
          type="button"
          onClick={arrayAppend}
          className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm"
        >
          Add Contact
        </button>
      </h2>
      {errors.length > 0 && (
        <div className="text-red-500 text-sm mb-4">
          {errors[0]}
        </div>
      )}
      <div className="space-y-4">
        {items.map((contact, index) => (
          <div
            key={`contact-${index}`}
            className="border border-gray-200 rounded-lg p-4 space-y-4"
          >
            <div className="flex justify-between items-center">
              <h4 className="font-medium text-gray-900">Contact {index + 1}</h4>
              <div className="flex gap-2">
                <button
                  type="button"
                  onClick={() => arrayMoveUp(index)}
                  disabled={index === 0}
                  className="text-blue-600 hover:text-blue-800 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
                >

                </button>
                <button
                  type="button"
                  onClick={() => arrayMoveDown(index)}
                  disabled={index === items.length - 1}
                  className="text-blue-600 hover:text-blue-800 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
                >

                </button>
                <button
                  type="button"
                  onClick={() => arrayRemove(index)}
                  className="text-red-600 hover:text-red-800 text-sm"
                >
                  Remove
                </button>
              </div>
            </div>

            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              <Field
                controller={controller}
                name={`contacts.${index}.name`}
                label="Name"
                type="text"
              />
              <Field
                controller={controller}
                name={`contacts.${index}.email`}
                label="Email"
                type="email"
              />
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

// Main Form Component
export default function ReactExample() {
  // Create form controller with memoization
  const { dataSource, validator } = useMemo(() => {
    const validatorFactory = new ValidatorFactory()

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

    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'
    }

    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',
    }

    return {
      dataSource: new PlainObjectDataSource(initialValues),
      validator: createEncolaValidatorFromRules(validatorFactory, rules, messages)
    }
  }, [])

  const formController = useMemo(() => {
    return createForm(dataSource, validator)
  }, [dataSource, validator])

  const { state, methods, controller } = useFormController(formController)

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

  const handleReset = () => {
    methods.reset()
  }

  return (
    <div className="bg-gray-100 min-h-screen p-4">
      <div className="mb-8">
        <div className="mb-8">
          <h1 className="text-3xl font-bold text-gray-900 mb-2">User Registration Form</h1>
          <p className="text-gray-600">Complete form with EncolaJS Validator and React hooks</p>

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

        <form className="space-y-8" onSubmit={handleSubmit}>
          {/* Basic Information */}
          <section>
            <h2 className="text-xl font-semibold text-gray-900 mb-4">Basic Information</h2>
            <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
              <Field
                controller={controller}
                name="name"
                label="Full Name"
                type="text"
                placeholder="Enter your full name"
              />
              <Field
                controller={controller}
                name="email"
                label="Email Address"
                type="email"
                placeholder="Enter your email"
              />
              <Field
                controller={controller}
                name="age"
                label="Age"
                type="number"
                min={18}
                max={120}
              />
            </div>
          </section>

          {/* Security */}
          <section>
            <h2 className="text-xl font-semibold text-gray-900 mb-4">Security</h2>
            <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
              <Field
                controller={controller}
                name="password"
                label="Password"
                type="password"
                placeholder="Create a secure password"
              />
              <Field
                controller={controller}
                name="confirmPassword"
                label="Confirm Password"
                type="password"
                placeholder="Confirm your password"
              />
            </div>
          </section>

          {/* Profile Information */}
          <section>
            <h2 className="text-xl font-semibold text-gray-900 mb-4">Profile Information</h2>
            <div className="space-y-4">
              <Field
                controller={controller}
                name="profile.bio"
                label="Bio"
                type="textarea"
                rows={3}
                placeholder="Tell us about yourself (optional)"
              />
              <Field
                controller={controller}
                name="profile.website"
                label="Website"
                type="url"
                placeholder="https://yourwebsite.com (optional)"
              />
            </div>
          </section>

          {/* Preferences */}
          <section>
            <h2 className="text-xl font-semibold text-gray-900 mb-4">Preferences</h2>
            <div className="space-y-4">
              <Checkbox
                controller={controller}
                name="preferences.newsletter"
                label="Subscribe to newsletter"
              />
              <Checkbox
                controller={controller}
                name="preferences.notifications"
                label="Enable notifications"
              />
            </div>
          </section>

          {/* Contacts Array */}
          <section>
            <ContactsArray controller={controller} />
          </section>

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

            <button
              type="submit"
              className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md"
            >
              Create Account
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}
jsx
import { useEffect, useState, useCallback } from 'react'
import { effect } from 'alien-signals'

/**
 * React hook for integrating with FormController
 * @param {IFormController} formController - The form controller instance
 * @returns {Object} Form state and methods
 */
export function useFormController(formController) {
  const [formState, setFormState] = useState({
    isDirty: formController.isDirty(),
    isTouched: formController.isTouched(),
    isValid: formController.isValid(),
    errors: formController.getErrors(),
    values: formController.getValues()
  })

  useEffect(() => {
    const effects = []

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

    // Error reactivity
    effects.push(effect(() => {
      formController.errorsChanged()
      setFormState(prev => ({
        ...prev,
        errors: { ...formController.getErrors() }
      }))
    }))

    // Cleanup effects on unmount
    return () => {
      effects.forEach(dispose => dispose?.())
    }
  }, [formController])

  const submit = useCallback(async () => {
    return await formController.submit()
  }, [formController])

  const reset = useCallback(() => {
    formController.reset()
  }, [formController])

  const setValue = useCallback((path, value, options) => {
    return formController.setValue(path, value, options)
  }, [formController])

  const getValue = useCallback((path) => {
    return formController.getValue(path)
  }, [formController])

  const getErrors = useCallback((path) => {
    if (path) {
      return formState.errors[path] || []
    }
    return formState.errors
  }, [formState.errors])

  const hasErrors = useCallback((path) => {
    const errors = formState.errors[path] || []
    return errors.length > 0
  }, [formState.errors])

  return {
    state: formState,
    methods: {
      submit,
      reset,
      setValue,
      getValue,
      getErrors,
      hasErrors
    },
    controller: formController
  }
}
jsx
import { useEffect, useState, useCallback } from 'react'
import { effect } from 'alien-signals'

/**
 * React hook for managing individual form fields
 * @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) {
  const [fieldState, setFieldState] = useState({
    value: formController.getValue(fieldPath),
    errors: formController.getErrors()[fieldPath] || [],
    hasErrors: (formController.getErrors()[fieldPath] || []).length > 0
  })

  useEffect(() => {
    const effects = []
    const field = formController.field(fieldPath)

    // Watch for changes to this specific field only
    effects.push(effect(() => {
      field.valueUpdated() // Subscribe to field-specific value changes
      setFieldState(prev => ({
        ...prev,
        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] || []
      setFieldState(prev => ({
        ...prev,
        errors,
        hasErrors: errors.length > 0
      }))
    }))

    // Cleanup effects on unmount
    return () => {
      effects.forEach(dispose => dispose?.())
    }
  }, [formController, fieldPath])

  const handleInput = useCallback((inputValue) => {
    formController.setValue(fieldPath, inputValue, {
      touched: true,
      dirty: false
    })
  }, [formController, fieldPath])

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

  return {
    value: fieldState.value,
    errors: fieldState.errors,
    hasErrors: fieldState.hasErrors,
    handleInput,
    handleChange
  }
}
jsx
import { useEffect, useState, useCallback } from 'react'
import { effect } from 'alien-signals'

/**
 * React hook for managing array form fields
 * @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 [arrayState, setArrayState] = useState(() => {
    const value = formController.getValue(fieldPath)
    return {
      items: Array.isArray(value) ? value : [],
      errors: formController.getErrors()[fieldPath] || []
    }
  })

  useEffect(() => {
    const effects = []
    const field = formController.field(fieldPath)

    // 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)
      setArrayState(prev => ({
        ...prev,
        items: Array.isArray(value) ? value : []
      }))
    }))

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

    // Cleanup effects on unmount
    return () => {
      effects.forEach(dispose => dispose?.())
    }
  }, [formController, fieldPath])

  const arrayAppend = useCallback(() => {
    const newItem = { ...defaultItem }
    formController.arrayAppend(fieldPath, newItem).catch(console.error)
  }, [formController, fieldPath, defaultItem])

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

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

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

  return {
    items: arrayState.items,
    errors: arrayState.errors,
    arrayAppend,
    arrayRemove,
    arrayMoveUp,
    arrayMoveDown
  }
}

React Hooks Pattern

The React integration uses a hooks-based pattern that encapsulates form logic into reusable hooks. Each hook manages its own state and effects, providing a clean separation of concerns.

Reactivity with alien-signals

The integration uses alien-signals effects inside React's useEffect to bridge between the FormController's signal-based reactivity and React's state management. When signals change, React state is updated, triggering component re-renders.

Field-specific Change Tracking: Each field has its own valueUpdated() method that returns an incrementing number when the field's value changes. Field hooks 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.

useMemo for Controller Creation

The FormController instance should be created once and memoized using useMemo to prevent unnecessary re-creation on each render. This is crucial for maintaining stable references and avoiding memory leaks from effect subscriptions.

Hook Responsibilities

  • useFormController: Creates form state, subscribes to form-level 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

Benefits of This Approach

  1. Declarative API: Hooks provide a clean, declarative way to integrate with FormController
  2. Optimized Reactivity: Field-specific subscriptions prevent unnecessary re-renders
  3. Composable: Hooks can be composed to build complex forms
  4. Framework Integration: Properly integrates with React's lifecycle and state management
  5. Type Safety: Works seamlessly with TypeScript for type-safe form handling
  6. Reusable Logic: Hooks can be reused across different projects and components

MIT Licensed