Forms
What You're Used To
React makes you wire every input manually:
jsx// React — controlled inputs require onChange + setState
const [name, setName] = useState('');
<input value={name} onChange={e => setName(e.target.value)} />Vue and Svelte give you two-way binding with v-model and bind:value. CellUI has bind().
bind(signal)
bind() creates two-way binding between a signal and a form element. State flows to the DOM and back:
typescriptimport { signal, view, bind } from '@cmj/cellui';
const name = signal('');
view`<input type="text" ${bind(name)} />`;
// That's it. The input shows the signal's value.
// Typing updates the signal. Changing the signal updates the input.How it works
bind() returns a directive that CellUI's template engine recognizes. When the template is processed:
- It calls
signal.bindNode(element, updater)to sync state → DOM - It attaches an
inputorchangeevent listener to sync DOM → state - For checkboxes/radios, it binds
.checkedinstead of.value - For selects, it listens to
changeinstead ofinput
Every input type
typescriptconst text = signal('');
const bio = signal('');
const agreed = signal(false);
const role = signal('developer');
view`
<!-- Text input -->
<input type="text" ${bind(text)} />
<!-- Textarea -->
<textarea ${bind(bio)}></textarea>
<!-- Checkbox -->
<label>
<input type="checkbox" ${bind(agreed)} />
I agree
</label>
<!-- Select -->
<select ${bind(role)}>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
</select>
`;Live preview pattern
Because signals are reactive, you can read bound values anywhere:
typescriptconst name = signal('');
const email = signal('');
view`
<form>
<input ${bind(name)} placeholder="Name" />
<input ${bind(email)} placeholder="Email" />
</form>
<div class="preview">
<p>Name: ${name}</p>
<p>Email: ${email}</p>
</div>
`;The preview updates as the user types. No manual wiring, no event handlers, no setState.
Validation with effect()
Use effect() to derive validation state from signals:
typescriptconst email = signal('');
const error = signal('');
effect(() => {
const val = email.value;
if (val && !val.includes('@')) {
error.value = 'Enter a valid email';
} else {
error.value = '';
}
});The validation runs automatically whenever email changes. No onBlur handler, no validation library, no form state object.
When NOT to use bind()
bind() is for simple form elements. It doesn't handle:
- Custom components — if you have a rich text editor or date picker, use event handlers directly
- Debounced input —
bind()updates on every keystroke. For search boxes, useoninputwith a debounce - Type coercion —
bind()always produces strings from text inputs. For numbers, parse manually:
typescriptconst rawAge = signal('');
const age = computed(() => parseInt(rawAge.value) || 0);
view`<input type="number" ${bind(rawAge)} />`;
// age.value is always a numberManual binding (when you need full control)
For custom behavior, wire signals manually:
typescriptconst search = signal('');
let debounceTimer: number;
view`
<input
value="${search}"
oninput="${(e: Event) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
search.value = (e.target as HTMLInputElement).value;
}, 300);
}}"
/>
`;This is more code than bind(), but gives you full control over timing and transformation.
Form submission
CellUI doesn't have a form library. Use standard onsubmit:
typescriptconst name = signal('');
const email = signal('');
const handleSubmit = (e: Event) => {
e.preventDefault();
const data = { name: name.value, email: email.value };
fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) });
};
view`
<form onsubmit="${handleSubmit}">
<input ${bind(name)} />
<input ${bind(email)} />
<button type="submit">Send</button>
</form>
`;If you need form validation libraries (Zod, Yup), use them normally — they work with plain values, which is what signal.value gives you.