Handling Events

Most web applications require some measure of interactivity, where the user interface updates according to interactions like clicks and form inputs. 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 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);
Loading...

Camel-cased event props are supported for compatibility reasons starting in 0.6.

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. For instance, in the following example, we have to filter events by target to make sure we’re not incrementing count based on clicks to the outer div.

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

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.

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.

The dispatchEvent() will also call any prop callbacks if they are found.

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 *CustomCounter() {
let count = 0;
const onmybuttonclick = () => {
count++;
this.refresh();
};

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

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

Using custom events and event bubbling allows you to encapsulate state transitions within component hierarchies without the need for complex state management solutions in a way that is DOM-compatible.

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. Crank supports both API styles for convenience and flexibility.

Form Elements

Because Crank uses explicit state updates, it doesn’t require “controlled” or “uncontrolled” form props. No render means no update.

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 needs to update for other reasons, you can use the special copy prop to prevent the input element from updating. The copy prop is a boolean which prevents an element and children from rerendering.

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="" copy={currentReset} />
<p>
<button onclick={onreset}>Reset</button>
</p>
</form>
);
}
}

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