Tags API for Class API Developers

TL;DR
  • Component Boundaries are far less meaningful, state lives locally
  • class, state, out, component, and other class component-specific APIs have been removed
  • Event handling is function-based instead of event-based

Marko 4 and 5 leveraged a Class-based API for interactivity. Marko 6 is based on a new tags-based syntax, where "everything is a tag". For developers familiar with older versions of Marko this will take some getting used to, but we are confident that the Tags API is cleaner and easier to write & read, and it increases Marko's capabilities and optimization potential.

This page will discuss some of the differences in mental model between the Class API and the Tags API.

Components Melt Away

The first idea to get used to when adopting the Tags API is that component boundaries have much less importance than they do in the Class API, and component-level methods no longer exist.

In the Class API, state and lifecycle are maintained at the component level. Each single-file component has its own state, onInput, onDestroy, and other lifecycle methods. The Tags API abandons this idea in favor of granular, compiled reactivity. State is declared with tag variables where necessary, including inside conditionals and loops, and lifecycle is attached to individual tags.

Patterns that depend on the component instance like getComponent have been removed and are replaced with local, declarative alternatives.

Updated APIs

A quick reference for removed features and their modern equivalents:

Event Handling

Attribute arguments like onClick("handleClick") have been removed. Event handlers are normal attributes now, and can be used either inline or by referencing a function:

<let/color="blue">
<button onClick() {
  color = "red";
}>Red</button>
<const/yellowOrGreen() {
  color = Math.random() > 0.5 ? "yellow" : "green";
}>
<button onClick=yellowOrGreen>Random</button>
<let/color="blue">
<button onClick() { color = "red" }>Red</button>

<const/yellowOrGreen() {
  color = Math.random() > 0.5 ? "yellow" : "green";
}>
<button onClick=yellowOrGreen>Random</button>
let/color="blue"
button onClick() {
  color = "red";
} -- Red
const/yellowOrGreen() {
  color = Math.random() > 0.5 ? "yellow" : "green";
}
button onClick=yellowOrGreen -- Random
let/color="blue"
button onClick() { color = "red" } -- Red

const/yellowOrGreen() {
  color = Math.random() > 0.5 ? "yellow" : "green";
}
button onClick=yellowOrGreen -- Random

This drastically simplifies custom tag communication, as function-based event handlers can be passed and called directly instead of curried as events. This means they're spreadable, and this.emit is no longer necessary.

two-buttons.marko
<!-- `onClick` and other event handlers are passed through!-->
<button ...input/>
<button
  ...input
  onClick(e, el) {
  console.log("clicked the second button");

  // call the parent `onClick` after doing something custom
  input.onClick && input.onClick(e, el);
}
/>
export interface Input extends Marko.HTML.Button {}

// `onClick` and other event handlers are passed through!
<button ...input/>

<button
  ...input
  onClick(e, el) {
  console.log("clicked the second button");

  // call the parent `onClick` after doing something custom
  input.onClick && input.onClick(e, el);
}
/>
<!-- `onClick` and other event handlers are passed through!-->
button ...input
button
  ,...input
  ,onClick(e, el) {
  console.log("clicked the second button");

  // call the parent `onClick` after doing something custom
  input.onClick && input.onClick(e, el);
}
export interface Input extends Marko.HTML.Button {}

// `onClick` and other event handlers are passed through!
button ...input

button
  ,...input
  ,onClick(e, el) {
  console.log("clicked the second button");

  // call the parent `onClick` after doing something custom
  input.onClick && input.onClick(e, el);
}

Information is passed to the parent via standard function parameters.

<for|i| until=5>
  <button onClick() {
    input.onClick?.(i);
  }>${i}</button>
</for>
export interface Input {
  onClick?: (i: number) => void;
}

<for|i| until=5>
  <button onClick() { input.onClick?.(i) }>${i}</button>
</for>
for|i| until=5
  button onClick() {
    input.onClick?.(i);
  } -- ${i}
export interface Input {
  onClick?: (i: number) => void;
}

for|i| until=5
  button onClick() { input.onClick?.(i) } -- ${i}

Element Refs

Instead of getEl, native tags expose a tag variable with a getter to the DOM node. Since it is a function it can be used anywhere in the template.

<input/$el>
<script>
  $el().focus();
</script>
<input/$el>

<script>$el().focus()</script>
input/$el
script
  --

    $el().focus();

  --
input/$el

script -- $el().focus()
Note

We use $el by convention. The leading $ is not necessarily required, but optimizations may be added that only apply if the convention is followed.

When references for multiple elements are required (like getEls in Marko 5), hoisted tag variables can be iterated.

<let/focus=0>
<for|i| until=5>
  <input/$el onFocus() {
    focus = i;
  }>
</for>
<button onClick() {
  const els = [...$el];
  focus = (focus + 1) % els.length;
  els[focus].focus();
}>
  Focus next
</button>
<let/focus=0>

<for|i| until=5>
  <input/$el onFocus() { focus = i }>
</for>

<button onClick() {
  const els = [...$el];
  focus = (focus + 1) % els.length;
  els[focus].focus();
}>
  Focus next
</button>
let/focus=0
for|i| until=5
  input/$el onFocus() {
    focus = i;
  }
button onClick() {
  const els = [...$el];
  focus = (focus + 1) % els.length;
  els[focus].focus();
} --
  Focus next
let/focus=0

for|i| until=5
  input/$el onFocus() { focus = i }

button onClick() {
  const els = [...$el];
  focus = (focus + 1) % els.length;
  els[focus].focus();
} --
  Focus next

Component Refs

Since component-level operations no longer exist in the Tags API, getComponent has been removed. Information should be passed between parent and child using event handlers, the controllable pattern, and methods exposed by the <return> tag.

parent.marko
<child/child/>
<child/{
  changeColor
}/>
<button onClick() {
  child.changeColor("blue");
  changeColor("green");
}>
  Change colors
</button>
<child/child/>
<child/{ changeColor }/>

<button onClick() {
  child.changeColor("blue");
  changeColor("green");
}>
  Change colors
</button>
child/child
child/{
  changeColor
}
button onClick() {
  child.changeColor("blue");
  changeColor("green");
} --
  Change colors
child/child
child/{ changeColor }

button onClick() {
  child.changeColor("blue");
  changeColor("green");
} --
  Change colors
child.marko
<let/color="red">
<button style={
  "background-color": color
}>${color}</button>
<return/{ changeColor(c) { color = c } }>
<let/color="red">
<button style={ "background-color": color }>${color}</button>

<return/{ changeColor(c) { color = c } }>
let/color="red"
button style={
  "background-color": color
} -- ${color}
return/{ changeColor(c) { color = c } }
let/color="red"
button style={ "background-color": color } -- ${color}

return/{ changeColor(c) { color = c } }

Lifecycle

In the Class API, running code when something mounts inside a loop requires a child component.

<!-- use class-->
<for|i| until=3>
  <log-value value=i/>
</for>
// use class
<for|i| until=3>
  <log-value value=i/>
</for>
<!-- use class-->
for|i| until=3
  log-value value=i
// use class
for|i| until=3
  log-value value=i
components/log-value.marko
<!-- use class-->
class {
  onMount() { console.log(this.input.value) }
}/>
// use class
class {
  onMount() { console.log(this.input.value) }
}
<!-- use class-->
class {
  onMount() { console.log(this.input.value) }
}/>
// use class
class {
  onMount() { console.log(this.input.value) }
}

In the Tags API, no component boundary is required.

<for|i| until=3>
  <script>
    console.log(i);
  </script>
</for>
<for|i| until=3>
  <script>console.log(i)</script>
</for>
for|i| until=3
  script
    --

        console.log(i);
     ${" "}
    --
for|i| until=3
  script -- console.log(i)

The Tags API has two built-in tags for lifecycle management, both of which should be avoided unless absolutely necessary.

<script>

The <script> tag is used in the Tags API to execute user effects.

<script>
  window.addEventListener("click", () => {
    console.log("clicked anywhere");
  }, {
    signal: $signal
  });
</script>
<script>
  window.addEventListener("click", () => {
    console.log("clicked anywhere");
  }, { signal: $signal });
</script>
script
  --

    window.addEventListener("click", () => {
      console.log("clicked anywhere");
    }, {
      signal: $signal
    });

  --
script
  --

    window.addEventListener("click", () => {
      console.log("clicked anywhere");
    }, { signal: $signal });

  --
Caution

The <script> tag is Marko's equivalent to React's useEffect, and as such it should be avoided unless side effects are required.

<lifecycle>

The <lifecycle> tag is for escaping the reactive system to work with imperative APIs (maps, charts, etc.). Each <lifecycle> tag manages its own onMount, onUpdate, and onDestroy, and this is stable across the tag's lifetime.

This tag should be used sparingly, usually <script> or regular state via <let>/<const> is a better option.

<let/latitude=0>
<let/longitude=0>
<lifecycle
  onMount() {
  this.map = new WorldMap($el());
  this.map.on("move", () => {
    latitude = this.map.x;
    longitude = this.map.y;
  });
}
  onUpdate() {
  this.map.setCoords(latitude, longitude);
}
  onDestroy() {
  this.map.destroy();
}
>
<div/$el/>
<let/latitude=0>
<let/longitude=0>

<lifecycle
  onMount() {
    this.map = new WorldMap($el());
    this.map.on("move", () => {
      latitude = this.map.x;
      longitude = this.map.y;
    });
  }
  onUpdate() {
    this.map.setCoords(latitude, longitude);
  }
  onDestroy() { this.map.destroy() }
>

<div/$el/>
let/latitude=0
let/longitude=0
lifecycle
  ,onMount() {
  this.map = new WorldMap($el());
  this.map.on("move", () => {
    latitude = this.map.x;
    longitude = this.map.y;
  });
}
  ,onUpdate() {
  this.map.setCoords(latitude, longitude);
}
  ,onDestroy() {
  this.map.destroy();
}
div/$el
let/latitude=0
let/longitude=0

lifecycle
  ,onMount() {
    this.map = new WorldMap($el());
    this.map.on("move", () => {
      latitude = this.map.x;
      longitude = this.map.y;
    });
  }
  ,onUpdate() {
    this.map.setCoords(latitude, longitude);
  }
  ,onDestroy() { this.map.destroy() }

div/$el

Derived values

In the Class API, there are multiple ways to derive values.

  1. Inline JS scriptlets with const

    const num = parseInt(input.num);
    <div>${num}</div>
    export interface Input {
      num: number | string;
    }
     const num = parseInt(input.num);
    <div>${num}</div>
    const num = parseInt(input.num);
    div -- ${num}
    export interface Input {
      num: number | string;
    }
     const num = parseInt(input.num);
    div -- ${num}
  2. Some Class API applications use onInput to validate or transform values. This is an anti-pattern and can lead to issues, but is still fairly common.

    class {
      onInput(input) {
        input.num = parseInt(input.num);
      }
    }/>
    <div>
      ${input.num}
    </div>
    export interface Input {
      num: number | string;
    }
    class {
      onInput(input) {
        input.num = parseInt(input.num);
      }
    }
    <div>${input.num}</div>
    class {
      onInput(input) {
        input.num = parseInt(input.num);
      }
    }/>
    <div>
      ${input.num}
    </div>
    export interface Input {
      num: number | string;
    }
    class {
      onInput(input) {
        input.num = parseInt(input.num);
      }
    }
    div -- ${input.num}

In the Tags API, all derivations should happen through the <const> tag.

<const/num=parseInt(input.num)>
<div>${num}</div>
export interface Input {
  num: number | string;
}
<const/num=parseInt(input.num)>
<div>${num}</div>
const/num=parseInt(input.num)
div -- ${num}
export interface Input {
  num: number | string;
}
const/num=parseInt(input.num)
div -- ${num}

Further Reading


Contributors

Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.