Skip to content
MocklyDocs

Build

Interactive parts

State, events, conditions, lists, and timers, in islands that load as small scripts.

Most of a Mockly page is HTML that's built when you publish. The parts that respond to a visitor, such as a menu, an accordion, or a calculator, are islands: components that come with a small script. The rest of the page ships no JavaScript.

An island renders on the server like any other component, so its HTML arrives complete. In the browser, Mockly doesn't render it again. It attaches the island's event handlers to that HTML and updates the parts that depend on state.

Your first island#

This component opens and closes an answer. It keeps one piece of state, changes it in a click handler, and shows the answer only while it's open:

components/faq-item.tsx
import { Show, state } from "mockly"; export function FaqItem({ q, a }: { q: string; a: string }) {  const open = state(false);  return (    <div class="border-b border-gray-200">      <button        type="button"        class="w-full py-4 text-left font-semibold"        aria-expanded={open.value}        onClick={() => {          open.value = !open.value;        }}      >        {q}      </button>      <Show when={open.value}>        <p class="pb-4 text-gray-600">{a}</p>      </Show>    </div>  );}

A page uses it like any component. Each FaqItem is its own island, with its own state:

pages/faq.tsx
import { For, Page } from "mockly";import { FaqItem } from "../components/faq-item"; const faqs = [  { q: "Can I cancel at any time?", a: "Yes. Your plan ends at the close of the billing period." },  { q: "Is there a free trial?", a: "Every plan starts with 14 days free." },]; export default function Faq() {  return (    <Page title="FAQ · Acme">      <main class="mx-auto max-w-2xl p-8">        <For each={faqs}>{(item) => <FaqItem q={item.q} a={item.a} />}</For>      </main>    </Page>  );}

What makes a component an island#

A component is an island if it does any of these:

  • calls state(), ref(), or media()
  • has an event handler, such as onClick, on an element or on a <Form>
  • renders <Window> or <Timer>
  • reads the .value of shared state

Every other component renders to HTML and ships nothing. An island returns exactly one HTML element as its root, and a page can't be an island: move the interactive part into its own component (MK3009).

State#

state(initial) returns an object with one property, value. Read it as open.value, and change it by assigning open.value = … inside an event handler.

  • Declare it as const x = state(initial) directly in the component's body, or at the top of a file for shared state (below) (MK3001). Its initial value can read props and constants, but not other state (MK3007).
  • Use .value, not the state object itself (MK3002).
  • Assign it only in event handlers (MK1012). Values never change in place: to change a list, build a new one with filter, toSorted, or a spread such as [...items.value, item], and assign it.

In the HTML, state has its initial value. Inside an island, an expression that reads state follows it in four places: text, attribute values, <Show when>, and <For each>. Everything else in the island is rendered once, when you publish.

Event handlers#

A handler is an inline arrow function on the element (MK2006):

TSX
<input  value={query.value}  onInput={(e) => {    query.value = e.currentTarget.value;  }}  onKeyDown={(e) => {    if (e.key === "Escape") {      query.value = "";    }  }}/>

The supported events are onClick, onDblClick, onInput, onChange, onSubmit, onKeyDown, onKeyUp, onFocus, onBlur, onMouseEnter, onMouseLeave, onPointerDown, onPointerUp, onPointerEnter, onPointerLeave, onScroll, onToggle (on <details>), onClose and onCancel (on <dialog>), and onClickOutside. Any other is MK2012.

A handler's body can hold expression statements, const declarations, if and else, and a bare return;. In it you can:

  • assign state with =, +=, -=, ++, or --
  • record a goal with track("goal-name") (see Goals)
  • call a ref's methods (below)
  • log with console.log(…)

The event#

The handler's parameter offers only these members:

MemberWhat it gives you
e.keyThe key pressed, in keyboard events
e.metaKey, e.ctrlKey, e.shiftKey, e.altKeyWhether a modifier key is held
e.currentTarget.valueThe value of the input, select, or textarea
e.currentTarget.checkedWhether a checkbox or radio button is checked
e.currentTarget.scrollTop, e.currentTarget.scrollLeftA scrolling element's position
e.currentTarget.scrollX, scrollY, innerWidth, innerHeightThe window's scroll position and size, in <Window onScroll> and onResize
e.preventDefault(), e.stopPropagation()Stop the browser's default action, or stop the event there

Clicks outside#

onClickOutside runs when a click lands anywhere outside the element, but only while the element is showing. It's how a menu or a popover closes. Put it on an element that also contains the button that opens it; otherwise a click on that button closes the element, and the button's own handler opens it again.

Conditions and lists#

<Show> renders its children when when is truthy, and fallback otherwise. <For> renders its one child, a function of each item and its index, for every item in each:

TSX
<Show when={plan === "pro"} fallback={<p>Upgrade to Pro</p>}>  <p>Welcome back</p></Show> <For each={features}>  {(feature, index) => <li>{index + 1}. {feature.title}</li>}</For>

Outside islands, and inside them when they don't read state, both are worked out when you publish, and only what they chose is in the HTML. Inside an island, a <Show> whose when reads state renders both branches and hides one with the hidden attribute, switching them as the state changes. A <For> whose each reads state is a list that changes in the browser.

Lists that change in the browser#

components/todo-list.tsx
import { For, state } from "mockly"; type Todo = { id: number; text: string }; export function TodoList({ initial }: { initial: Todo[] }) {  const todos = state(initial);  return (    <ul class="divide-y">      <For each={todos.value} key={(todo) => todo.id}>        {(todo) => (          <li class="flex justify-between py-2">            {todo.text}            <button              type="button"              onClick={() => {                todos.value = todos.value.filter((t) => t.id !== todo.id);              }}            >              Remove            </button>          </li>        )}      </For>    </ul>  );}

The server renders the items for the initial state, so the page is complete without JavaScript. In the browser, items are added, removed, and moved as the list changes, without a reload. Inside an item, text, attributes, and <Show>s that read the item follow it.

  • key names an item, so it keeps its element while it's on show. Without key, items are matched by identity: the same object or value. Lists made with filter, toSorted, or a spread keep their objects, so they work without it; use key when the objects are made anew, as map does. key can't read state.
  • Each item is one HTML element, and items can't use onClickOutside (MK3020).
  • Animation: a new item arrives with a data-mk-enter attribute, and a removed one gets data-mk-leave and stays until its transitions end. Style them with variants, such as class="transition-opacity duration-300 data-[mk-enter]:opacity-0 data-[mk-leave]:opacity-0". Items that transition transform also slide when they move. Without such CSS, items come and go at once.

Derived values#

A plain const can't read state, because it would go stale (MK3010). Use derived() for a value computed from state. It's recomputed whenever the state it reads changes:

TSX
const items = state<Item[]>([]);const total = derived(() => items.value.reduce((sum, item) => sum + item.price, 0)); <p>Total: {total.value}</p>

Read it as total.value anywhere state can be read: markup, handlers, <Timer when>, and other derived values. Declare it directly in a component's body or at the top of a file. It can't be assigned (MK3023).

Shared state#

state() at the top of a file is one value that every island on the page reads and writes. Export it, and import it by name in other files:

components/cart.tsx
import { state } from "mockly"; export const cartCount = state(0); export function AddToCart() {  return (    <button      type="button"      onClick={() => {        cartCount.value += 1;      }}    >      Add to cart    </button>  );}
components/cart-badge.tsx
import { cartCount } from "./cart"; export function CartBadge() {  return <span class="rounded-full bg-gray-900 px-2 text-white">{cartCount.value}</span>;}

Reading shared state makes CartBadge an island, so clicking Add to cart updates the badge. Every page starts from the initial value: shared state doesn't carry over to the next page or a reload. To keep it, remember it.

Remembered state#

Shared state with a remember key is kept in a cookie, so it lasts across pages, reloads, and visits:

components/announcement.tsx
import { Show, state } from "mockly"; export const announcementOpen = state(true, { remember: "announcement-3-0" }); export function Announcement() {  return (    <div>      <Show when={announcementOpen.value}>        <p class="bg-gray-900 p-2 text-center text-white">          Acme 3.0 is out.{" "}          <button            type="button"            onClick={() => {              announcementOpen.value = false;            }}          >            Dismiss          </button>        </p>      </Show>    </div>  );}

Every value is rendered when you publish, and the server sends the version that matches the visitor's cookie. So a dismissed banner is already gone from the HTML, with no flash.

  • Values must be known. A boolean is remembered as either value. A string or a number lists every value it can take in values, 2 to 8 of them, including the initial one: state("light", { remember: "theme", values: ["light", "dark"] }). The initial value is a literal.
  • A page can need at most 16 renders, one for each combination of the remembered values it reads.
  • Each key is used once per site. To show something again to everyone who dismissed it, such as a new announcement, use a new key.
  • Only state at the top of a file can be remembered. The rules are MK3019.

A string or number without values is remembered as the visitor left it: a cart count, or a name of up to 200 characters. The server fills it in where markup shows it as it is, such as {cartCount.value} or a whole attribute value. Other uses, such as {cartCount.value + 1}, render the initial value and stay hidden until the island starts, usually before the first paint. Use values when you can.

Islands that start later#

An island starts as the page loads. To start it later, give it load where you use it:

TSX
<Testimonials quotes={quotes} load="visible" /><FaqItem q={item.q} a={item.a} load="idle" />
  • load="visible" starts it as it comes within 200 pixels of the screen.
  • load="idle" starts it when the browser has nothing else to do, within 2 seconds.

Until it starts, the island is its HTML: text, links, and forms work, and track goals are counted, but its timers don't run. A press, a focus, or a click inside it starts it at once. A click that comes before its code has loaded is replayed when it's ready, so nothing a visitor does is lost. An island that doesn't use shared state, top-level constants, or a list that changes in the browser also downloads its script only when it starts.

load belongs to the page, so an island can't have a prop named load (MK3022).

Refs#

ref() names an element, so a handler can call its methods:

components/search-box.tsx
import { ref } from "mockly"; export function SearchBox() {  const input = ref();  return (    <div class="flex gap-2">      <input ref={input} type="search" name="q" aria-label="Search" class="rounded border px-3 py-2" />      <button        type="button"        onClick={() => {          input.focus();        }}      >        Search      </button>    </div>  );}

Handlers can call focus(), blur(), select(), scrollIntoView(), showModal(), show(), and close() on a ref, and nothing else (MK3014). On a <dialog>, showModal() and close() give you a modal with the browser's own focus handling, Escape key, and backdrop, which you style with backdrop: classes.

Declare it as const x = ref() directly in the component's body (MK3013), and put ref={x} on exactly one element of that component's own markup, outside any <For> (MK3015).

Media queries#

media() tells a handler whether a CSS media query matches:

TSX
const reduced = media("(prefers-reduced-motion: reduce)"); <Timer every={6000} when={!reduced.value} onTick={() => { slide.value += 1; }} />

Read .value in event handlers and <Timer when>; it's checked each time, so it's always current. Markup can't read it (MK3021), because the page is built before anyone sees it. Style markup with CSS instead, such as md:hidden or motion-reduce:transition-none.

Page-wide events#

<Window> listens on the whole window for as long as the page is open, and renders nothing:

TSX
<Window onKeyDown={(e) => { if (e.key === "Escape") { open.value = false; } }} />

It takes onKeyDown, onKeyUp, onClick, onPointerDown, onScroll, onResize, onFocus, and onBlur. Put it directly in the island's own markup, not inside <Show>, <For>, markup passed to a component, or a <Form> message, and give it no children (MK3016). To listen only sometimes, check state in the handler.

Timers#

<Timer> runs onTick every every milliseconds, or once after after milliseconds, from when the page loads. It renders nothing:

components/countdown.tsx
import { state, Timer } from "mockly"; export function Countdown({ seconds }: { seconds: number }) {  const left = state(seconds);  return (    <p class="font-mono">      <Timer        every={1000}        when={left.value > 0}        onTick={() => {          left.value -= 1;        }}      />      {left.value} seconds left    </p>  );}
  • It takes exactly one of every and after (MK3017). A time written as a number is from 50 to 2,147,483,647 milliseconds, about 24 days.
  • when runs it only while it's truthy. It starts again, counting from zero, each time when turns true, so the countdown above stops itself at zero.
  • The time can read state, such as every={speed.value}. A new time starts the timer again.
  • onTick follows the rules for handlers, but gets no event.
  • Like <Window>, it goes directly in the island's own markup, with no children.

There's no setTimeout or setInterval. In the HTML, nothing has happened yet: the countdown shows its starting value until the first tick.

Children and markup props#

An island can take markup, as children or as any other prop, and render it where it likes:

TSX
<Modal label="How it works">  <p>Any markup, including other islands.</p></Modal>

Markup renders on the server, as part of the island's HTML, and never reaches the browser as data. So the island's handlers and reactive expressions can't read a markup prop (MK3012), though rendering one, even inside a reactive <Show>, is fine. An island's other props reach the browser as JSON, so they're strings, numbers, booleans, null, and lists or objects of those.

Islands inside islands#

An island can render other islands anywhere in its markup: directly, inside a <For> or a reactive <Show>, or through plain components. Each one binds only its own handlers and reactive parts, and shared state connects them.

An island's props can read the state of the island that renders it, such as <Tab label={t} active={current.value === t} />. That island then starts it and keeps its props up to date, and it takes no load. A plain component can't take a value that reads state, because it renders when you publish (MK3008).

The one thing an island can't do is pass markup with its own handlers, refs, or reactive parts into another island (MK3018): the other island would bind them as its own.

Limits#

Islands follow the expression rules of every Mockly file, so they can't reach browser globals such as window, document, fetch, or localStorage (MK1007). <Window>, <Timer>, refs, and data refreshed in the browser (see Data from APIs) cover what those are usually for. The main island rules:

RuleCode
An island returns one root HTML element, and a page can't be an island.MK3009
state() is a const in a component's body or at the top of a file.MK3001
A const can't read state; use derived().MK3010
Handlers are inline arrow functions, on supported events.MK2006, MK2012
State is assigned, and track() called, only in handlers.MK1012, MK3011
A changing list has one HTML element per item, and its key can't read state.MK3020
Client code can't read a markup prop.MK3012
Markup can't carry one island's handlers into another island.MK3018
A plain component can't take a value that reads state.MK3008
Each ref is on exactly one element, and handlers only call its methods.MK3013, MK3014, MK3015
<Window> and <Timer> go directly in the island's own markup.MK3016, MK3017
Remembered state is top-level, with a unique key and at most 16 renders per page.MK3019
media() is read only in handlers and <Timer when>.MK3021
load is "visible" or "idle".MK3022
An <Experiment> can't be inside an island.MK4009

See Diagnostics for every code.