802: Package/UI
- Requires:
A schema-driven engine for generating dynamic, adaptable user interfaces directly from data structures. It treats the Schema as the backbone of design, allowing UIs to be stylized and reconfigured via declarative stylesheets and rendered through swappable adapters.
The UI Package implements a rendering engine where Structure is Design. Instead of manually crafting templates for every data type, it generates the interface automatically from the underlying JSON Schema. This ensures that the UI is always in sync with the data model—change the schema, and the interface updates instantly.
Core Philosophy: Structure as the Source of Truth
In traditional development, the data model and the UI often drift apart. This package solves that by making the Schema the single source of truth for the interface.
- Dynamic Generation: The UI is not hard-coded; it is derived. A change in the schema structure (e.g., adding a field, changing a type) is immediately reflected in the rendered output.
- Always Up-to-Date: Because the UI is a direct projection of the schema, it eliminates the class of bugs where the interface lags behind the data model.
Declarative Styling & Reconfiguration
While the Schema dictates what is rendered, the Stylesheet dictates how it looks. This separation allows for radical redesigns without touching the underlying structure.
- Declarative Layer: Stylesheets act as a configuration layer that maps schema nodes to visual properties.
- Reconfigurable: You can completely change the layout, spacing, and visual hierarchy by swapping stylesheets, effectively "skinning" the raw data structure.
Adapters & Interoperability
The engine is agnostic to the final rendering target. It uses an Adapter Pattern to translate the abstract component tree into concrete UI elements.
- Design System Support: Adapters can target specific design systems (e.g., Material UI, Ant Design), mapping abstract schema types to rich, pre-built components.
- HTML Fallback: A standard HTML adapter ensures that any schema can be rendered as semantic, accessible web content out of the box.
Multi-Modal Views
The same schema and data can be projected into different contexts using View Modes.
- Edit Mode: Generates fully interactive forms for data entry and validation.
- Show Mode: Renders read-only, optimized presentations for data consumption.
This allows a single definition to serve multiple purposes across an application, reducing code duplication and ensuring consistency between creating and viewing data.
Core Concepts
Dynamic Property System with Derivations
The system is built around a self-extending controller where modular properties register themselves and their inter-dependencies, creating a powerful and extensible derivation graph.
- Properties self-register: Each property module (e.g., for
data,schema,vars) automatically registers itself when imported. - Dependencies Declaration: Properties can declare dependencies on other properties (e.g.,
stylesdepends onvarsandsettings). - Chained Derivations: When a base property changes, the controller automatically re-derives any dependent properties in the correct order, ensuring the UI state is always consistent.
- Type safety: TypeScript infers the controller's and fields' types from the combined registered properties.
- Modular architecture: New properties, along with their derivation logic, can be added without modifying any existing code.
User-Defined Properties
The dynamic property system is the key to this extensibility. You can create and register your own custom properties to add new features and control any aspect of field behavior. This allows you to build powerful, domain-specific functionality directly into the rendering engine. For example, you could implement:
- A
slotsproperty that depends onstylesto determine which UI components to render. - An
errorsproperty that depends ondataandschemato perform validation. - Custom styling properties that react to specific data conditions.
Themable Component System
The second key to extensibility is the Theme system. It allows the rendering engine to be completely decoupled from any specific UI framework. This system orchestrates a clear flow of information from abstract data to a concrete UI:
- Schema Provides Structure: The
schemadefines the shape of the data and the overall hierarchy of the UI tree. - Controller Creates State: The controller processes the schema and data, creating a specific
statefor every field in the tree. - Variables (
vars) Declare Atoms: CSS variables are used to declaratively assignAtomcomponents to namedslots(e.g.,--slot-title: 'TitleAtom'). - Atoms are the Building Blocks:
Atomsare the leaf-level components (e.g.,<Input />,<Button />) that bind to the field'sstateto display data. - Fields Orchestrate Rendering: The
Fieldcomponent acts as an orchestrator, checking whichAtomsare assigned to its named slots via variables. - Rendering is Data-Driven: A
Fieldonly renders anAtomif the corresponding data for that slot exists in itsstate, ensuring a minimal UI. - Fields as Leaves: Represents a single data point (e.g. a string), composing multiple
Atomsinto a complete input (label, widget, description). - Fields as Branches (Fieldsets): A
Fieldcan also represent a "branch" (object/array), acting as a "fieldset" that provides layout for its childFields.
This clear separation of concerns allows for deep customization at every level, from the data processing logic to the final rendered pixel.
State Management & Structural Sharing
The controller centrally manages the tree's state, distinguishing between raw props, processed state, and the last rendered state to enable efficient change detection.
- Raw Props (
controller.props,controller.data, etc.): The original props passed to the root component. They serve as the source of truth and are never mutated by the processing pipeline. This enables support for both controlled and uncontrolled modes.- Controlled Mode: When
dataorvarsprops are provided, the system uses these external values. Updates triggeronChange/onVarsChangecallbacks. - Uncontrolled Mode: When only
initialDataorinitialVarsare provided, the controller manages state internally.
- Controlled Mode: When
- Current State (
controller.current): The processed state. After thestorephase, properties are processed (e.g., schema is collapsed, data is validated) and the result is stored incontroller.current. This is the state that is distributed to fields. - Last State (
controller.last): A shallow copy of thecurrentstate from the previous render cycle. It's used to compare against the newcurrentstate to detect exactly which properties and paths have changed.
Structural Sharing:
The architecture uses a single, shared state tree (controller.current) to minimize memory usage and ensure state consistency.
- Zero Duplication: Fields do not get their own copies of data. Instead, they hold references to slices of the
controller.currentstate tree. - State Coherency: Since fields directly reference slices of
controller.current, the tree's state data is always coherent. UI updates are then batched and rendered on the next tick for performance. - Descendant Visibility: Parent fields (e.g., for an object) have access to their entire sub-tree, including all nested data and schema.
The Update & Derivation Pipeline
The controller uses a single, unified pipeline for both initial renders and all subsequent updates. This ensures a consistent and predictable state flow. Here is a step-by-step breakdown of the process:
-
Trigger: An external event occurs (e.g., user input, API call). The corresponding property's
updatemethod is called, which checks for meaningful changes. If there are none, the process stops. -
Root Re-render: If a change is detected, a re-render of the root
<Form>component is triggered. This initiates the controller's core processing cycle. -
Store Raw Props: During the render, the controller first stores the raw props from the
<Form>component. -
Process Properties: The raw props are then processed into a consistent internal state (
controller.current). For example, theschemais collapsed. -
Build Field Tree: The controller discovers all field paths from the processed state and ensures a
fieldobject exists for each one. -
Distribute Changes: The controller compares the new
controller.currentstate with the previous state (controller.last) to identify the exact fields and properties that have changed. -
Invalidate & Derive: For each change detected,
controller.invalidate()is called. This is the entry point for the reactive derivation system and triggers:controller.rederive(): Computes new values for all dependent properties on the field (e.g.,settings,styles) in the correct topological order.controller.cascade(): Intelligently propagates the changes to descendant fields, triggering their own re-derive cycles.- Any field whose state is altered during this process is queued for a re-render.
-
Batched DOM Updates: After the React render cycle is complete, a
useLayoutEffecthook flushes the render queue. All fields that were queued during the derivation step are updated in the DOM in a single, efficient batch.
Performance & Efficiency
Efficient Rendering
The architecture is designed for high performance by minimizing React reconciliation and re-rendering overhead.
- Precise Change Detection: By diffing
controller.currentandcontroller.lastfor raw props, and using deep equality checks within the derivation cycle, the system knows exactly which fields and properties have changed, avoiding unnecessary updates. - Selective Invalidation & Derivation: Only fields affected by a change are invalidated. The derivation chain ensures that only dependent properties are re-computed.
- Deferred & Batched Rendering: Field render requests are queued during the processing cycle. The controller then flushes this queue in a single batch within
useLayoutEffect, minimizing render calls. - Render Deduplication: If multiple changes affect the same field within one cycle, it still only renders once.
Smart Cascading & Derivation
The system efficiently propagates variable changes down the field tree, similar to CSS variable inheritance, while minimizing re-computation.
- Cached Dependency Graph: The dependency relationship between all properties is calculated once and cached. The
rederiveprocess uses this cache to run derivations in the correct order without re-calculating it on every change. - Lazy Inheritance: Variables are inherited up the tree on-demand when a field computes its styles, using
controller.inherit(). - Selective Cascading: When a CSS variable (
var) changes on a field, thecascademethod propagates the change down to its descendants. The cascade stops at any descendant that defines its own local override for that specific variable. - Differential Updates: To avoid unnecessary re-renders, the
rederivelogic performs a deep-equality check on the result of eachderivefunction. A field is only flagged for re-rendering if a cascadedvarchange actually resulted in a different final state (e.g., a differentstyleobject), preventing wasteful renders. - Consequence-Based Rendering: This means that an external change (e.g., updating a
var) will only trigger a re-render if it actually causes a meaningful change in a derived property that affects the UI. If avarchange is overridden by a more specific rule and results in the same finalstyleoutput, no wasteful render will occur.
Example Update Flow
The unified pipeline handles all updates. The derivation cycle is an integral part of the "Distribute" phase.
// User updates a CSS variable on the 'user.name' field
await controller.update('user.name', 'vars', { '--field-color': 'red' });
What happens internally:
controller.update()calls theupdatemethod on theVarsProperty.- The
updatemethod detects a change and callscontroller.render(), triggering a re-render of the root component. - The controller runs its processing cycle: it stores raw props, processes them into the new
controller.currentstate, and builds the field tree. - During the distribute step, the controller finds that
varsonuser.namehas changed and callscontroller.invalidate('user.name', 'vars'). controller.invalidate()is the entry point for the derivation logic:- It updates
field.varson the 'user.name' field. - It calls
controller.rederive(field, ['vars']). - It calls
controller.cascade(field, ['vars']).
- It updates
- Derivation & Cascade:
rederive()runs the dependency chain for the 'user.name' field, updating its derivedstylesand queueing it for a render.cascade()recursively propagates the change to children, triggering theirrederiveprocess. useLayoutEffectrun, flushing the render queue and updating the DOM.
Architecture Diagram: Update Lifecycle
API Reference
Controller Methods
// Update a field property
controller.update(path: string, property: string, value: any): Promise<boolean>
// Merge with existing property value
controller.merge(path: string, property: string, value: object): Promise<boolean>
// Get property value
controller.get(property: string, path?: string): any
// Inherit property value up the tree
controller.inherit(property: string, path: string, key?: string): any
// Register field subscriber
controller.register(path: string, forceRender: () => void): () => void
Property Registration Example
Properties are self-contained objects defining lifecycle methods to manage a specific aspect of the tree's state.
const StylesProperty = {
priority: 50,
fieldDefaults: { styles: {} },
// Declare that this property depends on 'vars' and 'settings'
dependencies: ['vars', 'settings'],
// --- Lifecycle Methods ---
// Computes the 'styles' object based on the field's current state.
// Runs automatically when 'vars' or 'settings' change.
derive: field => {
const newStyles = getComputedFieldStyles(
field.mode,
varName => field.controller.inherit('vars', field.path, varName),
field.type
);
return { styles: newStyles };
},
// Handles updates from a field, like controller.update('path', 'styles', ...)
// This is less common for a purely derived property.
update: (field, controller, value) => {
return false; // Typically, derived properties are not manually updatable.
},
// Called by controller.update() to kick off the derivation process.
invalidate: (field, controller, newValue, oldValue) => {
// Invalidate is simpler for derived properties. The main logic is in `derive`.
// The controller's rederive logic will handle re-computation.
// For a base property like 'vars', it would trigger the chain.
controller.rederive(field, ['styles']);
controller.cascade(field, ['styles']);
},
};
Property.register('styles', StylesProperty);
This architecture provides a robust foundation for complex UI rendering while maintaining excellent performance and developer experience.