Some things are not state, and element() is how you reach them
Nearly everything on a page can be a value. A name, a count, whether the drawer is open: all of those are variables, and the page follows them.
Then you hit the first thing that is not a value at all. You want the cursor to land in a text field after the reader presses a button, and there is no number you can set that means "focused".
A DOM node is not state. element() hands you a claim ticket for one node, and you cash it in
later, inside a handler.
Three props, three jobs
Every host element in Markless accepts the same three kinds of prop, and it helps to see them together before any of them makes sense on its own:
onClick={} runs event behaviour owned by this node
el={} gives lazy access to this node later
attach={} installs longer-lived DOM behaviour owned by this node
onClick you already know from events. This page is the other two.
el binds a handle to exactly one node
Call element() in the component body to make a handle, hang it on one element with el, and read
it inside a handler:
import { element, state } from '@markless/core';
export default function FocusField() @{
let field = element<HTMLInputElement>();
let status = state('The cursor is somewhere else');
<section>
<label>
Your favourite word
<input el={field} />
</label>
<button
type="button"
onClick={() => {
field?.focus();
status = 'The cursor is in the field';
}}
>
Put the cursor in the field
</button>
<p>{status}</p>
</section>
}
field is the real <input> once the browser has one, so field.focus() is the DOM method, called
on the DOM node, with no wrapper in between. Give the handle the element type you expect and
TypeScript knows the methods: element<HTMLInputElement>() gets you value, select() and the
rest.
Try it: press the button, watch the cursor move
Press the button below without touching the field, and watch the caret appear in the box:
The cursor is somewhere else
Nothing about that click read or wrote the input's contents. The handler asked the browser to focus a node, which is a thing you do to a node and not a thing you store in a variable. That is exactly why it is not state.
attach is where code that owns a node lives
The other half of the same story is the library that wants a <div> of its own. A chart, a code
editor, a map, a drag handler, an IntersectionObserver. Those all want to be handed an element,
and they want to be told when it goes away.
That is attach. It is a function that receives the element, and it may return a cleanup function:
import { state } from '@markless/core';
export default function Host() @{
let taps = state(0);
<p
data-host
attach={(element) => {
element.setAttribute('data-behavior', 'on');
}}
onClick={() => taps++}
>
Host, tapped {taps} times
</p>
}
In a real app the function usually comes from somewhere else, which keeps the markup readable:
import { installYouTubeController } from './youtube-controller.ts';
export default function Player() @{
<div class="player" attach={installYouTubeController} />
}
The rules are short. A behaviour may return a cleanup function, which runs when the element goes
away. An array of behaviours installs in the order you wrote it and cleans up in reverse. And
attach belongs on a host element, never on a component: putting it on a component is the
diagnostic MARKLESS_ATTACH_HOST_ELEMENT_REQUIRED, because a component is not a node and there is
nothing to hand the function.
What a behaviour costs on the wire
Nothing until it is needed. A behaviour's result is never serialized: the payload stores the code reference and the serializable inputs, and the behaviour code is fetched when a real trigger asks for it. This is the same bargain as an event handler. Your chart library is not in the bytes the first paint waits on.
It also means a behaviour must be able to rebuild whatever it owns from serializable inputs. A live object such as a socket or a chart instance is not something the payload can carry, and the state graph will tell you so rather than quietly dropping it: "Move it to a host element behavior or recreate it from serializable state".
Coming from another framework?
A handle is not a box with a .current on it, and it is not a mutable slot you are meant to keep other values in. It is one node or nothing. It also does not fire an effect when it is populated: there is no dependency array to add it to and no lifecycle callback that hands it to you, because the moment you actually want it is inside the handler you already wrote. Behaviours are the other half of that trade. Where you might reach for an effect that runs after mount to wire up a library, you hand the library the element instead, and you return the teardown from the same function that did the setup, so the two halves cannot drift apart.
Try it yourself
Change the button so it calls field?.select() instead of field?.focus(), type something into the
field first, and watch the whole word highlight. Then add a second <input> with no el on it and
notice the button still knows exactly which field it means, because a handle is bound to one element
and not to a name.
