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:

  1. It calls signal.bindNode(element, updater) to sync state → DOM
  2. It attaches an input or change event listener to sync DOM → state
  3. For checkboxes/radios, it binds .checked instead of .value
  4. For selects, it listens to change instead of input

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 inputbind() updates on every keystroke. For search boxes, use oninput with a debounce
  • Type coercionbind() 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 number

Manual 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.