Events

For a web application to be interactive, there needs to be a way to respond to user events. This is done by registering callback functions in the JSX template.

const Counter = component$(() => {
  const store = useStore({ count: 0 });

  return <button onClick$={() => store.count++}>{store.count}</button>;
});

In the above example, the onClick$ attribute of the <button> element is used to let Qwik know that a callback () => store.count++ should be executed whenever the click event is fired by the <button>.

Notice that onClick$ ends with $. This is a hint to both the Qwik Optimizer and the developer that a special transformation occurs at this location. The presence of the $ suffix implies a lazy-loaded boundary here. The code associated with the click handler will not download until the user triggers the click event. See Optimizer Rules for more details.

In the above example, the click listener is trivial in implementation. But in real applications, the listener may refer to complex code. By creating a lazy-loaded boundary, Qwik can tree-shake all of the code behind the click listener and delay its loading until the user clicks the button.

Prevent default

Because of the async nature of Qwik, an event's handler execution might be delayed because the implementation has not been downloaded yet. This introduces a problem when the event's handler needs to prevent the default behavior of the event. Traditional event.preventDefault() will not work, so instead use Qwik's preventdefault:{eventName} attribute:

const PreventDefaultExample = component$(() => {
  return (
    <a
      href="/about"
      preventdefault:click // This will prevent the default behavior of the "click" event.
      onClick$={(event) => {
        // PreventDefault will not work here, because handle is dispatched asynchronously.
        // event.preventDefault();
        singlePageNavigate('/about');
      }}
    >
      Go to about page
    </a>
  );
});

Window and Document events

So far, we have discussed how to listen to events that originate from elements. There are events (for example, scroll and mousemove) that require that we listen to them on the window or document. For this reason, Qwik allows for the document:on and window:on prefixes when listening for events.

const EventExample = component$(() => {
  const store = useStore({
    scroll: 0,
    mouse: { x: 0, y: 0 },
    clickCount: 0,
  });

  return (
    <button
      window:onScroll$={(e) => (store.scroll = window.scrollY)}
      document:onMouseMove$={(e) => {
        store.mouse.x = e.x;
        store.mouse.y = e.y;
      }}
      onClick$={() => store.clickCount++}
    >
      scroll: {store.scroll}
      mouseMove: {store.mouse.x}, {store.mouse.y}
      click: {store.clickCount}
    </button>
  );
});

The purpose of the window:on/document: is to register an event at a current DOM location of the component but have it receive events from the window/document. There are two advantages to this:

  1. The events can be registered declaratively in your JSX
  2. The events get automatically cleaned up when the component is destroyed. (No explicit bookkeeping and cleanup is needed)

Events and Components

Components are functions, not elements. Since DOM events do not exist naturally, it's possible to define custom events as props.

Now let's look at how one declares a child component that can be used with events.

import { PropFunction } from '@builder.io/qwik';

interface CmpButtonProps {
  onClick$?: PropFunction<() => void>;
}

const CmpButton = component$((props: CmpButtonProps) => {
  return (
    <button onDblclick$={props.onClick$}>
      <Slot />
    </button>
  );
});

As far as Qwik is concerned, passing events to a component is equivalent to passing as props. In our example, we declare all props in CmpButtonProps interface. Specifically, notice onClick$: PropFunction<() => void> declaration.

On the usage side, when referring to the <CmpButton>:

<CmpButton onClick$={() => store.cmpCount++}>{store.cmpCount}</CmpButton>

Working with QRLs

Let's look at a variation of the above <CmpButton> implementation. In this example, we would like to demonstrate the passing of callbacks to components. For this reason, we have created an additional listener onClick$

interface CmpButtonProps {
  onClick$?: PropFunction<() => number>;
}

const CmpButton = component$((props: CmpButtonProps) => {
  return (
    <button
      onDblclick$={props.onClick$}
      onClick$={async () => {
        const nu = await props?.onClick$();
        console.log('clicked', nu);
      }}
    >
      <Slot />
    </button>
  );
});

Notice that we can pass the props.onClick$ method directly to the onDblclick$ attribute as seen on <button> element. (see attribute onDblclick$={props.onClick$}) This is because both the inputting prop onClick$ as well as JSX prop onDblclick are of type PropFunction<?> (and both have $ suffix).

However, it is not possible to pass props.onClick$ to onClick$ because the types don't match. (This would result in the type error: onClick$={props.onClick$}) Instead, the $ is reserved for inlined closures. In our example, we would like to console.log("clicked") after we process the props.onClick callback. We can do so with the props.onClick$() method. This method will:

  1. Lazy load the code
  2. Restore the closure state
  3. Invoke the closure

The operation is asynchronous and therefore returns a promise, which we can resolve using the await statement.

State recovery

const Counter = component$(() => {
  const store = useStore({ count: 0 });

  return <button onClick$={() => store.count++}>{store.count}</button>;
});

At first sight, it may appear that Qwik simply lazy loads the onClick$ function. But upon closer inspection, it is important to realize that Qwik lazy loads a closure rather than a function. (A closure is a function that lexically captures the state inside its variables. In other words, closures carry state, whereas functions do not.) The capturing of the state is what allows the Qwik application to simply resume where the server left off because the recovered closure carries the state of the application with it.

In our case, the onClick$ closure captures store. The capturing of store allows the application to increment the count property on click without having to re-run the whole application. Let's look at how closure capturing works in Qwik.

The HTML generated by the above code would look something like this:

<div>
  <button q:obj="1" on:click="./chunk-a.js#Counter_button_onClick[0]">0</button>
</div>

Notice that on:click attribute contains three pieces of information:

  1. ./chunk-a.js: The file which needs to be lazy-loaded.
  2. Counter_button_onClick: The symbol which needs to be retrieved from the lazy-loaded chunk.
  3. [0]: An array of lexically captured variable references (State of the closure).

In our case () => store.count++ only captures store, and hence it contains only a single reference 0. 0 is an index into the q:obj attribute that contains a reference to the actual serialized object, referring to store. (The exact mechanisms and syntax is an implementation detail that can change at any time)

Qwikloader

For the browser to understand the on:click attribute syntax, a small JavaScript script known as the Qwikloader is needed. The Qwikloader is small (about 1kb) and fast (about 5ms) in execution. The Qwikloader script is inlined into the HTML so that it can be executed quickly. When a user interacts with the application, the browser fires relevant events that bubble up the DOM. At the root of the DOM, the Qwikloader listens for the events and then tries to locate the corresponding on:<event> attribute. If such an attribute is found, then the value of the attribute is used to resolve the location where code can be downloaded from and then executes it.

See Qwikloader for more details.

Made with โค๏ธ by