How Formily's Effects System Manages Side Effects: A Deep Dive into the Heart Architecture
Formily's effects system isolates side effects through a dedicated subsystem centered on lifecycle types, effect hooks, and a central Heart manager that batches updates for optimal performance.
The Formily form solution (maintained by Alibaba) provides a sophisticated effects system that separates side-effect logic from UI components. This system manages reactions to form initialization, field value changes, validation events, and component lifecycles through a declarative API. Understanding how Formily's effects system manages side effects requires examining three core concepts: lifecycle types, effect hooks, and the central Heart manager.
Core Architecture of the Formily Effects System
Formily delegates all side-effect management to a specialized subsystem built around the Heart class. This architecture ensures that reactions to state changes remain predictable, type-safe, and performant.
Lifecycle Types and the Heart Manager
At the foundation lies the LifeCycleTypes enum defined in packages/core/src/types.ts, which enumerates every hookable moment in a form or field's existence. These include initialization, mounting, value changes, submission, validation start/end, and unmounting events.
The Heart (implemented in packages/core/src/models/Heart.ts) serves as the lifecycle manager. It maintains a registry of callbacks organized by LifeCycleTypes keys. When a form or field emits a lifecycle event, the Heart retrieves the corresponding queue of callbacks and executes them synchronously.
The createEffectHook Factory
All effect hooks in Formily originate from the createEffectHook function located in packages/core/src/shared/effective.ts. This factory function registers a callback for a specific lifecycle type while guaranteeing execution inside a batch operation. Batching ensures that multiple state updates triggered by a side effect collapse into a single reactive update cycle, preventing unnecessary re-renders.
// Conceptual implementation from effective.ts
createEffectHook(
type: LifeCycleTypes,
handler: (target, form) => (callback) => void
)
Creating Effect Hooks for Forms and Fields
Formily distinguishes between form-level effects (global form state) and field-level effects (specific input controls), though both leverage the same underlying machinery.
Form-Level Effect Hooks
Form-level hooks like onFormInit, onFormMount, and onFormSubmit are generated by the createFormEffect factory in packages/core/src/shared/onFormEffects.ts. Each hook wraps the user callback in a batch operation to ensure atomic execution:
// Excerpt from onFormEffects.ts
function createFormEffect(type: LifeCycleTypes) {
return createEffectHook(
type,
(form: Form) => (callback: (form: Form) => void) => {
batch(() => {
callback(form) // Executes atomically within a single batch
})
}
)
}
export const onFormInit = createFormEffect(LifeCycleTypes.ON_FORM_INIT)
export const onFormMount = createFormEffect(LifeCycleTypes.ON_FORM_MOUNT)
When onFormMount is called within an effects setup function, it registers the provided callback to execute when the form enters the mounted state, with all state changes batched automatically.
Field-Level Effect Hooks with Path Patterns
Field-level hooks such as onFieldValueChange, onFieldMount, and onFieldValidateStart follow a similar pattern but introduce path pattern matching. The createFieldEffect factory (in packages/core/src/shared/onFieldEffects.ts) checks whether the target field's address matches the provided pattern before invoking the callback:
// Excerpt from onFieldEffects.ts
function createFieldEffect<Result extends GeneralField = GeneralField>(type: LifeCycleTypes) {
return createEffectHook(
type,
(field: Result, form: Form) =>
(pattern: FormPathPattern, callback: (field: Result, form: Form) => void) => {
if (FormPath.parse(pattern).matchAliasGroup(field.address, field.path)) {
batch(() => {
callback(field, form)
})
}
}
)
}
export const onFieldValueChange = createFieldEffect<DataField>(LifeCycleTypes.ON_FIELD_VALUE_CHANGE)
This pattern matching allows developers to target specific fields using glob patterns like user.name or data.*.value, ensuring effects run only on relevant field instances.
Registering and Triggering Effects
Developers interact with the effects system primarily through the useFormEffects hook, which bridges the React or Vue component layer with the core Form model.
Using useFormEffects
In Vue applications, the hook is implemented in packages/vue/src/hooks/useFormEffects.ts:
export const useFormEffects = (effects?: (form: Form) => void): void => {
const formRef = useForm()
if (effects) formRef.value.addEffects(id, effects)
}
The React implementation follows an identical pattern. Developers provide a function that receives the form instance and calls various onForm* or onField* helpers:
useFormEffects(form => {
onFormMount(() => console.log('Form mounted'))
onFieldValueChange('data.*', (field) => console.log('Field changed:', field.path))
})
How the Heart Executes Callbacks
When addEffects is called (defined in packages/core/src/models/Form.ts at lines 460-480), it forwards the user-provided effects to the Heart:
// Form.ts excerpt
addEffects = (id: any, effects: IFormProps['effects']) => {
this.heart.addLifeCycles(id, runEffects(this, effects))
}
The runEffects function invokes the user callback with the form instance, allowing immediate registration of all lifecycle hooks. When a lifecycle event fires (e.g., a field value updates), the Heart iterates over stored callbacks for that specific LifeCycleTypes and executes them. Because each callback was wrapped in batch during creation, all resulting state mutations consolidate into a single update cycle.
Reactive Effects and Automatic Cleanup
Beyond lifecycle hooks, Formily provides continuous observation capabilities through the underlying @formily/reactive engine.
autorun and reaction Helpers
The onFormReact and onFieldReact helpers create persistent observers using autorun, while onFieldChange utilizes reaction to watch specific field state keys (value, error, visible, etc.):
// onFormReact implementation concept
export function onFormReact(callback?: (form: Form) => void) {
let dispose = null
onFormInit(form => {
dispose = autorun(() => {
if (isFn(callback)) callback(form)
})
})
onFormUnmount(() => {
dispose()
})
}
These reactive helpers store the disposable observer in the field's disposers array. When the field or form unmounts, Formily automatically invokes these disposers, preventing memory leaks and ensuring clean detachment from the reactive graph.
Summary
- Formily's effects system centralizes side-effect management through the Heart class, isolating reactions from UI rendering logic.
createEffectHookgenerates all effect hooks, automatically wrapping callbacks inbatchto optimize performance.- Path pattern matching in field-level hooks enables targeted reactions to specific form controls using
FormPath.parsematching. - Registration occurs through
useFormEffects, which callsform.addEffectsto populate the Heart's lifecycle queues. - Reactive helpers like
onFormReactleverageautorunfor continuous observation, with automatic cleanup via stored disposers.
Frequently Asked Questions
What is the Heart in Formily?
The Heart is the central lifecycle manager located in packages/core/src/models/Heart.ts that stores and executes effect callbacks. It maintains a map of lifecycle queues keyed by LifeCycleTypes, retrieving and executing the appropriate callbacks when form or field events fire. The Heart ensures all callbacks run synchronously and in the correct order.
How does Formily batch state updates in effects?
Formily wraps every effect callback in the batch function from @formily/reactive. This ensures that any state mutations occurring within the callback (such as setting field values or modifying form state) are deferred and executed as a single atomic update. Batching prevents intermediate states from triggering redundant re-renders, significantly improving performance in complex forms.
What is the difference between onFieldValueChange and onFieldReact?
onFieldValueChange is a lifecycle hook that fires once when a field's value changes, executing the callback inside a batch. onFieldReact creates a persistent autorun observer that re-executes whenever any observed value within the callback changes, not just the field's value. Use onFieldValueChange for one-time reactions to value mutations; use onFieldReact for continuous reactive computations that depend on multiple observable properties.
How do I clean up side effects when a form unmounts?
Formily handles cleanup automatically for reactive effects by storing autorun and reaction disposables in the field's internal disposers array. When a field or form unmounts, these disposers are invoked automatically. For manual cleanup, use the onFormUnmount or onFieldUnmount hooks to register teardown logic, which the Heart executes when the component leaves the tree.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →