LIGHTWEIGHT TEMPLATES
It is a plain assignment: when the element is created, the compiler assigns the DOM node itself to x. So x holds an HTMLInputElement, and you read .value, .checked or call .focus() on it directly.
JS tab
let emailInput;
let acceptsTerms;
JSX tab
<input type="email" ref={emailInput} required />
<input type="checkbox" ref={acceptsTerms} />
<PianoPrimaryButton
onClick={() => submit()}
externalEventName="signup"
externalEventParams={{
email: emailInput?.value,
accepted: acceptsTerms?.checked,
}}
>
<T>Sign up</T>
</PianoPrimaryButton>
Three rules make the difference between this working and failing silently:
-
Declare it with
letat the top level of the JS tab. See the previous question. -
It is empty until the element is mounted. Reading
emailInput.valuewhile the JS tab runs — or inside acreateEffectthat fires before mount — throws. Read it from an event handler, fromonMount, or from a prop that is evaluated on interaction. -
Use optional chaining.
emailInput?.value. If the element sits inside a<Show>that has not rendered, the variable is stillundefined, and a throw inside a click handler loses the click's side effects without any visible error.
Ref or a signal? Pick ref when the value is only ever read at interaction time; you get the live DOM value for free, with no wiring. Pick a signal when the value also drives rendering: a character counter, a conditional block, a computed label. A ref holds an element, not reactive state, so nothing re-renders when its contents change.
// JS tab — a signal, because the length is rendered as well as read
const [subject, setSubject] = createSignal("");
<input type="text" maxlength="60" onInput={(e) => setSubject(e.currentTarget.value)} />
<p>{subject().length} / 60</p>
A full worked example using both in one form: Lightweight templates external events, Example 2.