Upgrade Guides
Upgrading from 0.x.x to 1.x.x (canary)
It’s been one year since the stable release of this library. While no one enjoys breaking changes, extensive feedback and real-world usage made it clear that some core paradigms needed to be rethought from the ground up. This first major release brings significant benefits—everything is now more powerful, scalable, and composable.
The core philosophy of this release is to ensure that all previously supported use cases remain intact—while also enabling use cases that were previously impossible or required workarounds. At first, some things might feel more complex compared to the previous version, but as your product evolves, you'll quickly realize the power and intent behind the new approach. It unlocks capabilities that were simply not feasible before.
This guide focuses solely on breaking changes.
You should know!
The most significant changes occurred in the React package. Importantly, the schema structure remains unchanged—so all your accumulated data will continue to work without any issues.
Upgrading dependencies
Install version 1 of @coltorapps/builder
and @coltorapps/builder-react
using your preferred package manager:
pnpm install @coltorapps/builder@canary @coltorapps/builder-react@canary
Core Breaking Changes
Runtime compatibility
The library now ships as ESM-only, dropping support for CommonJS and legacy module systems.
This change aligns with modern best practices and ensures optimal compatibility with today's build tools, browsers, and server runtimes.
If you're using older tools that expect CommonJS, we recommend upgrading your toolchain to support ESM (e.g., use a modern bundler like Vite, Webpack 5+, or a newer Node.js version).
Attributes names
You no longer need to define names for attributes within their definitions. Instead, when including attributes in an entity definition, you now provide them as a record object rather than an array. Each key in the record serves as the attribute name for that specific entity.
const labelAttribute = createAttribute({
validate(value) {
return z.string().parse(value);
},
});
const textFieldEntity = createEntity({
attributes: { label: labelAttribute },
});
Previously, attributes were defined as arrays in entities, which introduced the risk of having duplicate attribute names. With the new record-based approach, this concern is eliminated. Additionally, assigning a name to an attribute is now the responsibility of the entity that uses it, not the attribute definition itself—making attributes fully reusable and context-agnostic.
Entity names
You no longer need to assign names within entity definitions. Instead, when including entities in a builder definition, you provide them as a record. Each key in the record acts as the entity name for that specific builder.
const textFieldEntity = createEntity({
attributes: { label: labelAttribute },
});
const formBuilder = createBuilder({
entities: { textField: textFieldEntity },
});
Previously, entities were defined as arrays in builders, which introduced the risk of having duplicate entity names. With the new record-based approach, this concern is eliminated. Additionally, assigning a name to an entity is now the responsibility of the builder that uses it, not the entity definition itself—making entities fully reusable and context-agnostic.
Entities context
Entity definition methods such as validate
, defaultValue
, and shouldBeProcessed
receive a context object as part of their arguments. Previously, this context contained entitiesValues
, a record of raw entity values, which is now removed.
The context now provides a complete record of all entities via ctx.entities
, not just their values. Each entry includes full entity information (type, attributes, metadata, etc.) alongside the current value of the entity. This change enables more powerful and context-aware logic inside your validation and lifecycle methods.
Each entity entry in the new context has the following properties:
Property | Type | Description |
---|---|---|
id | string | The unique ID of the entity. |
type | string | The entity type key, as defined in the builder. |
attributes | object | A record of the entity’s attribute values. |
value | TValue optional | The current runtime value of the entity. |
metadata | TMetadata | Metadata defined by you in the entity definition. |
parentId | string optional | The ID of the parent entity, if any. |
children | array optional | An array of child entity IDs, if any. |
This structure allows entity lifecycle methods to reason about the full state of all entities in the schema, not just their values.
Stores subscriptions
The callback passed to the subscribe
method in builder and interpreter stores no longer receives specific events. Instead, it receives both the new and previous data snapshots.
The event-driven approach proved to be cumbersome and difficult to scale in complex UIs. By relying on data comparisons instead of events, all data consumers (such as React hooks) gain more flexibility and benefit from a significantly improved developer experience.
React Breaking Changes
Store event listeners
Since the builder and interpreter stores no longer emit events, useBuilderStore
and useInterpreterStore
no longer accept event listeners. Instead, you should use the useEntityAdded
, useEntityDeleted
, useAttributeValueUpdated
, and useEntityValueUpdated
hooks.
function App() {
const builderStore = useBuilderStore(formBuilder);
useEntityAdded(builderStore, (entity) => {
console.log("Entity added:", entity);
});
useEntityDeleted(builderStore, (entity) => {
console.log("Entity deleted:", entity);
});
useAttributeValueUpdated(builderStore, (entity) => {
console.log(`Attribute "${entity.updatedAttributeName}" updated`, entity);
});
const interpreterStore = useInterpreterStore(formBuilder, formSchema);
useEntityValueUpdated(interpreterStore, (entity) => {
console.log(
`Entity ${entity.id} value changed`,
"from",
entity.prevValue,
"to",
entity.value,
);
});
return null;
}
These hooks work by observing data updates and comparisons rather than relying on emitted events, enabling a more intuitive flow for building UI systems.
Consuming store data
The useBuilderStoreData
and useInterpreterStoreData
hooks now accept an optional selector, allowing fine-grained control over re-renders and data selection. Previously, the second argument was used to decide whether a render should be triggered based on emitted events. Re-rendering is now handled automatically by comparing the previous and current data slices.
function App() {
const builderStore = useBuilderStore(formBuilder);
const rootIds = useBuilderStoreData(builderStore, (data) => data.schema.root);
const interpreterStore = useInterpreterStore(formBuilder, formSchema);
const someEntityValue = useInterpreterStoreData(
interpreterStore,
(data) => data.entitiesValues["32e5f795-332e-42d4-8680-d21019c5aa34"],
);
return null;
}
In rare cases where you’re working with complex data structures that can’t be compared using the built-in shallow comparator, you can provide a custom comparator as the third argument to minimize re-renders.
Components
The createEntityComponent
function is deprecated. This abstraction provided no meaningful value and introduced certain limitations.
Previously, a single entity component was used for both builders and interpreters, which violated the single responsibility principle.
Overview:
- The
<BuilderEntities />
and<BuilderEntity />
components accept entity components whose props satisfy theBuilderEntityComponentProps<typeof myEntityDefinition>
interface. - The
<InterpreterEntities />
and<InterpreterEntity />
components accept entity components whose props satisfy theInterpreterEntityComponentProps<typeof myEntityDefinition>
interface. - Entity components no longer reactively receive attribute values, validation errors, values, etc. You can now consume this data using the new React hooks, demonstrated below.
Example of defining a builder and interpreter entity component for a text field entity:
import {
type EntityAttributesValues,
type EntityValue,
} from "@coltorapps/builder";
import {
useEntityAttributesValues,
useEntityError,
useEntityValue,
type BuilderEntityComponentProps,
type InterpreterEntityComponentProps,
} from "@coltorapps/builder-react";
import { type textFieldEntity } from "./entities";
interface TextFieldProps
extends EntityAttributesValues<typeof textFieldEntity> {
id: string;
value?: EntityValue<typeof textFieldEntity>;
onChange?: (value: EntityValue<typeof textFieldEntity>) => void;
}
function TextField(props: TextFieldProps) {
return (
<div>
<label htmlFor={props.id} aria-required={props.required}>
{props.label.trim() ? props.label : "Label"}
</label>
<input
id={props.id}
name={props.id}
value={props.value ?? ""}
onChange={(e) => props.onChange?.(e.target.value)}
placeholder={props.placeholder}
required={props.required}
/>
</div>
);
}
export function BuilderTextFieldEntity(
props: BuilderEntityComponentProps<typeof textFieldEntity>,
) {
const attributes = useEntityAttributesValues(props.entity);
return <TextField id={props.entity.id} {...attributes} />;
}
export function InterpreterTextFieldEntity(
props: InterpreterEntityComponentProps<typeof textFieldEntity>,
) {
const value = useEntityValue(props.entity);
const error = useEntityError(props.entity);
return (
<div>
<TextField
id={props.entity.id}
value={value}
onChange={(value) => props.entity.setValue(value)}
// Attributes values are static in interpreters.
// We don't need a hook to consume these values.
{...props.entity.attributes}
/>
{error ? <p className="text-red-500">{String(error)}</p> : null}
</div>
);
}
We’ve implemented a TextField
component—a reusable presentational component that accepts the field’s attributes, current value, and an onChange
handler. This component is reused by both the builder and interpreter components of the textFieldEntity
.
The new builder and interpreter components can now be used respectively within builders and interpreters:
<BuilderEntities
builderStore={builderStore}
components={{ textField: BuilderTextFieldEntity }}
/>
<InterpreterEntities
interpreterStore={interpreterStore}
components={{ textField: InterpreterTextFieldEntity }}
/>
There are many new hooks available to help you optimize your app and implement any UX you envision:
useAttributeValue
: Subscribes to updates of a specific attribute's value and returns the selected data. Used only in builder components.useAttributeError
: Subscribes to the error state of a specific attribute and returns the selected result. Used only in builder components.useEntityAttributesValues
: Subscribes to all attribute value updates of a given entity and returns the result. Used only in builder components.useEntityAttributesErrors
: Subscribes to all attribute error updates of a given entity and returns the result. Used only in builder components.useEntityValue
: Subscribes to the value of a specific interpreter entity instance and returns the result. Used only in interpreter components.useEntityError
: Subscribes to the validation error of a specific interpreter entity instance and returns the result. Used only in interpreter components.
All of these hooks provide fine-grained control over reactivity and re-renders by allowing selectors and comparators to be passed. All of them are type-safe, ensuring they are used in the appropriate context (builder or interpreter).
The createAttributeComponent
function is also deprecated. Like its counterpart, it offered little value and imposed limitations.
The concept of an "attribute component" no longer exists. A builder entity component can now serve as the editor for its own attributes.
import {
useAttributeError,
useAttributeValue,
type AttributeInstance,
type BuilderEntityComponentProps,
} from "@coltorapps/builder-react";
import { type labelAttribute, type requiredAttribute } from "./attributes";
import { type textFieldEntity } from "./entities";
export function LabelAttributeEditor(props: {
attribute: AttributeInstance<typeof labelAttribute>;
}) {
const value = useAttributeValue(props.attribute);
const error = useAttributeError(props.attribute);
return (
<div>
<label htmlFor={props.attribute.type} aria-required>
Label
</label>
<input
id={props.attribute.type}
name={props.attribute.type}
value={value ?? ""}
onChange={(e) => props.attribute.setValue(e.target.value)}
required
/>
{error ? <p className="text-red-500">{String(error)}</p> : null}
</div>
);
}
export function RequiredAttributeEditor(props: {
attribute: AttributeInstance<typeof requiredAttribute>;
}) {
const value = useAttributeValue(props.attribute);
const error = useAttributeError(props.attribute);
return (
<div>
<label htmlFor={props.attribute.type} aria-required>
Required
</label>
<input
id={props.attribute.type}
name={props.attribute.type}
type="checkbox"
checked={value}
onChange={(e) => props.attribute.setValue(e.target.checked)}
/>
{error ? <p className="text-red-500">{String(error)}</p> : null}
</div>
);
}
export function TextFieldAttributesEditor(
props: BuilderEntityComponentProps<typeof textFieldEntity>,
) {
return (
<div>
<LabelAttribute attribute={props.entity.attributes.label} />
<RequiredAttribute attribute={props.entity.attributes.required} />
</div>
);
}
You can then use the <BuilderEntity />
component to render the attribute editor for a specific entity:
activeEntityId ? (
<BuilderEntity
key={activeEntityId}
entityId={activeEntityId}
builderStore={builderStore}
components={{ textField: TextFieldAttributesEditor }}
/>
) : null;
This new approach is far more composable and enables new use cases—such as interdependencies between attribute values:
export function MinLengthAttributeEditor(props: {
maxLengthAttribute: AttributeInstance<typeof maxLengthAttribute>;
minLengthAttribute: AttributeInstance<typeof minLengthAttribute>;
}) {
const minLength = useAttributeValue(props.minLengthAttribute);
const maxLength = useAttributeValue(props.maxLengthAttribute);
return (
<input
type="number"
max={maxLength}
value={minLength}
onChange={(e) => props.minLengthAttribute.setValue(e.target.value)}
placeholder="Min Length"
/>
);
}
export function MaxLengthAttributeEditor(props: {
maxLengthAttribute: AttributeInstance<typeof maxLengthAttribute>;
minLengthAttribute: AttributeInstance<typeof minLengthAttribute>;
}) {
const maxLength = useAttributeValue(props.maxLengthAttribute);
const minLength = useAttributeValue(props.minLengthAttribute);
return (
<input
type="number"
min={minLength ?? 0}
value={maxLength}
onChange={(e) => props.maxLengthAttribute.setValue(e.target.value)}
placeholder="Max Length"
/>
);
}
export function TextFieldAttributesEditor(
props: BuilderEntityComponentProps<typeof textFieldEntity>,
) {
return (
<div>
<MinLengthAttributeEditor
minLengthAttribute={props.entity.attributes.minLength}
maxLengthAttribute={props.entity.attributes.maxLength}
/>
<MaxLengthAttributeEditor
maxLengthAttribute={props.entity.attributes.maxLength}
minLengthAttribute={props.entity.attributes.minLength}
/>
</div>
);
}
It’s good to know that both BuilderEntityComponentProps
and InterpreterEntityComponentProps
accept a second generic argument to constrain the component to a specific builder:
export function BuilderTextFieldEntity(
props: BuilderEntityComponentProps<
typeof textFieldEntity,
typeof formBuilder
>,
) {
// `props.builderStore` is inferred from `formBuilder`
return <input />;
}
export function InterpreterTextFieldEntity(
props: InterpreterEntityComponentProps<
typeof textFieldEntity,
typeof formBuilder
>,
) {
// `props.interpreterStore` is inferred from `formBuilder`
return <input />;
}
Children rendering
Entities that have children—such as a section entity—previously received all rendered children via props.children
. Now, this prop is an array of child IDs. The BuilderEntityComponentProps
and InterpreterEntityComponentProps
interfaces provide two computed components via props: <props.RenderChildren />
and <props.RenderChild />
.
<props.RenderChildren />
essentially acts as a slot, just like props.children
did in the previous version. It simply renders all child entities:
export function BuilderSectionEntity(
props: BuilderEntityComponentProps<typeof sectionEntity>,
) {
return (
<div>
<h3>My Section</h3>
<props.RenderChildren />
</div>
);
}
<props.RenderChild />
renders a single entity and is especially useful when you need fine-grained control over the rendering of child entities—such as when implementing virtualization for long lists or other advanced use cases:
export function BuilderSectionEntity(
props: BuilderEntityComponentProps<typeof sectionEntity>,
) {
return (
<div>
<h3>My Section</h3>
{props.children.map((id) => (
<props.RenderChild key={id} entityId={id} />
))}
</div>
);
}
Both components accept a render prop that wraps each child recursively:
<props.RenderChildren>
{(props) => <div className="border border-blue-500">{props.children}</div>}
</props.RenderChildren>;
props.children.map((id) => (
<props.RenderChild key={id} entityId={id}>
{(props) => <div className="border border-blue-500">{props.children}</div>}
</props.RenderChild>
));
If you pass a render prop to <BuilderEntities />
, <BuilderEntity />
, <InterpreterEntities />
, or <InterpreterEntity />
, as well as to <props.RenderChildren />
or <props.RenderChild />
, all wrappers will be applied recursively and stack together.
Thank you!
A big thank you to everyone using the library ❤️ and sharing feedback. Your input drives these updates and helps us clearly identify what’s missing.
If you notice anything missing from this guide or encounter any issues, don’t hesitate to open an issue or reach out on Discord.