Handling Events

🚧 The docs are a work in progress.🚧

Most web applications require some measure of interactivity, where the user interface updates according to input. To facilitate this, Crank provides several ways to listen to and trigger events.

DOM Event Props

You can attach event callbacks to host element directly using event props. These props start with on, are all lowercase, and correspond to the event type (onclick, onkeydown). By combining event props, local variables and this.refresh(), you can write interactive components.

import {renderer} from "@b9g/crank/dom";
function *Counter() {
let count = 0;
const onclick = () => {
count++;
this.refresh();
};

for ({} of this) {
yield (
<button onclick={onclick}>
Button pressed {count} time{count !== 1 && "s"}.
</button>
);
}
}

renderer.render(<Counter />, document.body);

The EventTarget Interface

As an alternative to event props, Crank contexts implement the same EventTarget interface used by the DOM. The addEventListener() method attaches a listener to a component’s root DOM node.

import {renderer} from "@b9g/crank/dom";
function *Counter() {
let count = 0;
this.addEventListener("click", () => {
count++;
this.refresh();
});

for ({} of this) {
yield (
<button onclick={onclick}>
Button pressed {count} time{count !== 1 && "s"}.
</button>
);
}
}

renderer.render(<Counter />, document.body);
Loading...

The context’s addEventListener() method attaches to the top-level node or nodes which each component renders, so if you want to listen to events on a nested node, you must use event delegation.

While the removeEventListener() method is implemented, you do not have to call the removeEventListener() method if you merely want to remove event listeners when the component is unmounted.

Because the event listener is attached to the outer div, we have to filter events by ev.target.tagName in the listener to make sure we’re not incrementing count based on clicks which don’t target the button element.

import {renderer} from "@b9g/crank/dom";

function *Counter() {
let count = 0;
this.addEventListener("click", (ev) => {
if (ev.target.tagName === "BUTTON") {
count++;
this.refresh();
}
});

for ({} of this) {
yield (
<div>
<p>The button has been clicked {count} time{count !== 1 && "s"}.</p>
<button>Increment</button>
</div>
);
}
}

renderer.render(<Counter />, document.body);
Loading...

Event props vs EventTarget

The props-based event API and the context-based EventTarget API both have their advantages. On the one hand, using event props means you can listen to exactly the element you’d like to listen to.

On the other hand, using the addEventListener method allows you to take full advantage of the EventTarget API, which includes registering passive event listeners, or listeners which are dispatched during the capture phase. Additionally, the EventTarget API can be used without referencing or accessing the child elements which a component renders, meaning you can use it to listen to elements nested in other components.

Crank supports both API styles for convenience and flexibility.

Dispatching Events

Crank contexts implement the full EventTarget interface, meaning you can use the dispatchEvent method and the CustomEvent class to dispatch custom events to ancestor components:

import {renderer} from "@b9g/crank/dom";
function MyButton(props) {
this.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("mybuttonclick", {
bubbles: true,
detail: {id: props.id},
}));
});

return (
<button {...props} />
);
}

function MyButtons() {
return [1, 2, 3, 4, 5].map((i) => (
<p>
<MyButton id={"button" + i}>Button {i}</MyButton>
</p>
));
}

function *MyApp() {
let lastId;
this.addEventListener("mybuttonclick", (ev) => {
lastId = ev.detail.id;
this.refresh();
});

for ({} of this) {
yield (
<div>
<MyButtons />
<p>
{lastId == null
? "No buttons have been pressed."
: `The last pressed button had an id of ${lastId}`}
</p>
</div>
);
}
}

renderer.render(<MyApp />, document.body);
Loading...

MyButton is a function component which wraps a <button> element. It dispatches a CustomEvent whose type is "mybuttonclick" when it is pressed, and whose detail property contains data about the pressed button. This event is not triggered on the underlying DOM nodes; instead, it can be listened for by parent component contexts using event capturing and bubbling, and in the example, the event propagates and is handled by the MyApp component.

Using custom events and event bubbling allows you to encapsulate state transitions within component hierarchies without the need for complex state management solutions used in other frameworks like Redux or VueX.

Form Elements

Because Crank uses explicit state updates, it doesn’t have a concept of “controlled” vs “uncontrolled” props like value/defaultValue in React. No update means the value is uncontrolled.

import {renderer} from "@b9g/crank/dom";
function *Form() {
let reset = false;
const onreset = () => {
reset = true;
this.refresh();
};

const onsubmit = (ev) => {
ev.preventDefault();
};

for ({} of this) {
yield (
<form onsubmit={onsubmit}>
<input type="text" value="" />
<p>
<button onclick={onreset}>Reset</button>
</p>
</form>
);
}
}

renderer.render(<Form />, document.body);
Loading...

If your component is updating for other reasons, you can use the special property $static to prevent the input element from updating.

import {renderer} from "@b9g/crank/dom";
function *Form() {
let reset = false;
const onreset = () => {
reset = true;
this.refresh();
};

const onsubmit = (ev) => {
ev.preventDefault();
};

setInterval(() => {
this.refresh();
}, 1000);

for ({} of this) {
const currentReset = reset;
reset = false;
yield (
<form onsubmit={onsubmit}>
<input type="text" value="" $static={currentReset} />
<p>
<button onclick={onreset}>Reset</button>
</p>
</form>
);
}
}

renderer.render(<Form />, document.body);
Loading...