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.model to 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}:

TSX
Loading preview...

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#

TSX
Loading preview...

Checkbox (Boolean)#

For a single checkbox, bind to a boolean value:

TSX
Loading preview...

Checkbox (Array)#

For multiple checkboxes, bind to an array. The checkbox's value is added/removed from the array:

TSX
Loading preview...

Radio Buttons#

Radio buttons update the bound value with their value attribute:

TSX
Loading preview...

Select#

TSX
Loading preview...

Textarea#

TSX
Loading preview...

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:

PropertyDescriptionUse Case
props.model.valueCurrent value (read/write)Reading or updating the value
props.modelThe model object itselfForwarding 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:

  1. Clean forwarding: props.model can be passed directly to child components
  2. Always reactive: Model value reads in render are tracked
  3. Unified access: Read and write via props.model.value
  4. Type safety: Full TypeScript inference for model types

Define.Model Type#

Define.Model<T> creates:

  • A props.model object with .value accessor
  • The model object for forwarding to children
  • An update:modelValue event for emitting updates
TSX
Loading preview...

Named Model Props#

For components that need multiple two-way bindings, use the two-argument form of Define.Model:

TSX
Loading preview...

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:

TSX
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:

TSX
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:

TSX
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.value inside the render function

How It Works#

When you write model={() => state.property}:

  1. Getter function: The () => state.property is a function that accesses a reactive property
  2. Detection: SignalX's detectAccess() function intercepts the property access to extract [stateObj, "property"]
  3. JSX Processing: The JSX runtime transforms this into:
    • For components: A Model<T> object on props with value accessor and binding info
    • For native elements: Sets up value binding and event listeners
  4. 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:

TSX
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:

TSX
// ✅ 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:

TSX
Loading preview...

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:

TSX
// ✅ 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:

TSX
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:

TSX
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:

TSX
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:

TSX
Loading preview...