Two-Way Binding
SignalX provides a powerful model directive for two-way data binding between your reactive state and form elements or custom components.
Overview
The model binding system separates concerns cleanly:
- For callers: Use
model={() => state.property}to create a two-way binding - For components: Access
props.modelto read/write values and forward bindings
This architecture enables seamless binding forwarding to child components while maintaining full reactivity.
Model on Native Elements
The simplest way to use two-way binding is with native HTML elements. Just use model={() => state.property}:
The model prop accepts a getter function that returns a property from a reactive signal. SignalX automatically:
- Sets the element's value from your state
- Updates your state when the user types
Supported Elements
Text & Number Inputs
Checkbox (Boolean)
For a single checkbox, bind to a boolean value:
Checkbox (Array)
For multiple checkboxes, bind to an array. The checkbox's value is added/removed from the array:
Radio Buttons
Radio buttons update the bound value with their value attribute:
Select
Textarea
Model on Custom Components
To create a component that supports model binding, use the Define.Model<T> type helper. Components receive model bindings through a dedicated models object in the setup context, separate from props.
The Model Object
When a component receives a model binding, SignalX provides a Model<T> object on props with:
| Property | Description | Use Case |
|---|---|---|
props.model.value | Current value (read/write) | Reading or updating the value |
props.model | The model object itself | Forwarding to child components |
For named models like model:title, you get props.title.value and props.title.
Why Model Objects?
The Model<T> object on props provides several benefits:
- Clean forwarding:
props.modelcan be passed directly to child components - Always reactive: Model value reads in render are tracked
- Unified access: Read and write via
props.model.value - Type safety: Full TypeScript inference for model types
Define.Model Type
Define.Model<T> creates:
- A
props.modelobject with.valueaccessor - The model object for forwarding to children
- An
update:modelValueevent for emitting updates
Named Model Props
For components that need multiple two-way bindings, use the two-argument form of Define.Model:
Forwarding Model Bindings
One of the most powerful features of SignalX's model architecture is seamless forwarding to child components.
Direct Forwarding
Since props.model is a Model<T> object, you can pass it directly to child components:
import { component, type Define } from 'sigx';
type WrapperProps = Define.Model<string>;
const Wrapper = component<WrapperProps>(({ props, slots }) => {
return () => (
<div class="wrapper">
{/* Forward model binding directly to child */}
<Input model={props.model} />
</div>
);
});
// Usage: binding flows through Wrapper to Input
<Wrapper model={() => form.email} />
The child component receives the same reactive binding—changes propagate all the way back to the original signal.
Forwarding Named Models
For named models, use the corresponding model object from props:
type FormFieldProps =
& Define.Model<'text', string>
& Define.Model<'error', string>;
const FormField = component<FormFieldProps>(({ props }) => {
return () => (
<div class="field">
{/* Forward to different child components */}
<Input model={props.text} />
<ErrorMessage model={props.error} />
</div>
);
});
// Usage
<FormField
model:text={() => form.email}
model:error={() => form.emailError}
/>
Forwarding via Context
For deeply nested components (like menu items in a menu), provide the binding through context. Use a getter for clean reactive access:
import { component, defineInjectable, defineProvide, type Define } from 'sigx';
// Context with value getter and setter
const menuContext = defineInjectable<{
readonly value: string | undefined;
setValue: (v: string) => void;
}>(() => ({
value: undefined,
setValue: () => {}
}));
type MenuProps = Define.Model<string>;
const Menu = component<MenuProps>(({ props, slots }) => {
// Provide reactive value access via getter
defineProvide(menuContext, () => ({
get value() { return props.model?.value; },
setValue: (v: string) => { if (props.model) props.model.value = v; }
}));
return () => (
<ul class="menu">
{slots.default?.()}
</ul>
);
});
type MenuItemProps = Define.Prop<'value', string>;
const MenuItem = component<MenuItemProps>(({ props, slots }) => {
const ctx = menuContext();
return () => {
const isActive = ctx.value === props.value;
return (
<li
class={isActive ? "active" : ""}
onClick={() => ctx.setValue(props.value)}
>
{slots.default?.()}
</li>
);
};
});
// Usage
const Nav = component(({ signal }) => {
const nav = signal({ active: 'home' });
return () => (
<Menu model={() => nav.active}>
<MenuItem value="home">Home</MenuItem>
<MenuItem value="about">About</MenuItem>
<MenuItem value="contact">Contact</MenuItem>
</Menu>
);
});
This approach:
- Clean reads:
ctx.value— the getter is called automatically - Clean writes:
ctx.setValue(v)— explicit setter call - Stays reactive: The getter accesses
props.model.valueinside the render function
How It Works
When you write model={() => state.property}:
- Getter function: The
() => state.propertyis a function that accesses a reactive property - Detection: SignalX's
detectAccess()function intercepts the property access to extract[stateObj, "property"] - JSX Processing: The JSX runtime transforms this into:
- For components: A
Model<T>object onpropswithvalueaccessor and binding info - For native elements: Sets up value binding and event listeners
- For components: A
- Component receives: The model object is available on
props.model
Component Model Flow
Caller: model={() => state.property}
↓
JSX Runtime: detectAccess → [state, "property"]
↓
VNode: props.$models = { model: Model<T> }
↓
Renderer: Merges model into props
↓
Component: Access via props.model.value
↓
Update: props.model.value = newValue → state.property = newValue
Forwarding Flow
Parent receives: model={() => nav.active}
↓
Parent's props.model = Model<string>
↓
Forward: <Child model={props.model} />
↓
Child receives same model object
↓
Child props.model.value = x updates original nav.active
Best Practices
Use props.model for Model Values
Access model values from the props object:
const Input = component<Define.Model<string>>(({ props }) => {
return () => (
<input
value={props.model?.value ?? ''}
onInput={(e) => { if (props.model) props.model.value = (e.target as HTMLInputElement).value; }}
/>
);
});
Use Signals or Writable Computed
Bind to signal properties or writable computed values:
// ✅ Good - binds to signal property
<input model={() => state.name} />
// ✅ Good - writable computed works with model
<input model={() => fullName.value} />
// ❌ Bad - read-only computed isn't settable
<input model={() => readOnlyComputed.value} />
// ❌ Bad - expressions aren't bindable
<input model={() => state.name.toUpperCase()} />
Writable Computed Example
Writable computed values work seamlessly with model binding, enabling transformations in both directions:
The fahrenheit computed automatically converts between units. When you type in the Fahrenheit field, the setter updates state.celsius, which in turn updates the Fahrenheit display.
Forward with props.model
When forwarding bindings, pass the model object from props:
// ✅ Good - forwards the model object
<ChildInput model={props.model} />
// ✅ Good - named model forwarding
<ChildInput model={props.title} />
Type Your Define.Model Props
For TypeScript users, always specify the type:
type FormProps = Define.Model<string>; // For string values
type ToggleProps = Define.Model<boolean>; // For boolean values
type CounterProps = Define.Model<number>; // For number values
// Named models
type EditorProps = Define.Model<'content', string>;
Combine with Other Props
Define.Model composes with other type helpers:
import { type Define } from 'sigx';
type InputProps =
& Define.Model<string>
& Define.Prop<'placeholder', string, false>
& Define.Prop<'disabled', boolean, false>
& Define.Event<'blur', FocusEvent>;
Model Type
For advanced use cases, you can import the Model<T> type:
import { Model } from 'sigx';
// Model<T> provides:
// - .value: T (read/write)
// - .binding: [stateObj, key, handler] (internal)
type ContextType = {
model?: Model<string>;
};
This is useful when:
- Creating context types for model forwarding
- Building wrapper components that pass bindings
- Writing utility functions that work with models
Complete Example
Here's a complete form example showing the full model binding architecture: