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