Rocket

Rocket is currently in alpha – available in the Datastar Pro repo.

Rocket is a Datastar Pro plugin that bridges Web Components with Datastar’s reactive system. It allows you to create encapsulated, reusable components with reactive data binding.

Rocket is a powerful feature, and should be used sparingly. For most applications, standard Datastar templates and global signals are sufficient. Reserve Rocket for cases where component encapsulation is essential, such as integrating third-party libraries or creating complex, reusable UI elements.

Basic example #

Traditional web components require verbose class definitions and manual DOM management. Rocket eliminates this complexity with a declarative, template-based approach.

Here’s a Rocket component compared to a vanilla web component.

 1<template data-rocket:simple-counter
 2          data-prop:count="int (min 0)"
 3          data-prop:start="int (min 0)"
 4          data-prop:step="int (clamp 1 10) (= 1)"
 5>
 6  <script>
 7    $$count = $$start
 8  </script>
 9  <button data-on:click="$$count -= $$step">-</button>
10  <span data-text="$$count"></span>
11  <button data-on:click="$$count += $$step">+</button>
12  <button data-on:click="$$count = $$start">Reset</button>
13</template>
  1class SimpleCounter extends HTMLElement {
  2  static observedAttributes = ['start', 'step'];
  3  
  4  constructor() {
  5    super();
  6    this.innerHTML = `
  7      <div class="error" style="display: none;"></div>
  8      <button class="dec">-</button>
  9      <span class="count">0</span>
 10      <button class="inc">+</button>
 11      <button class="reset">Reset</button>
 12    `;
 13    
 14    this.errorEl = this.querySelector('.error');
 15    this.decBtn = this.querySelector('.dec');
 16    this.incBtn = this.querySelector('.inc');
 17    this.resetBtn = this.querySelector('.reset');
 18    this.countEl = this.querySelector('.count');
 19    
 20    this.handleDec = () => { 
 21      const newValue = this.count - this.step;
 22      if (newValue >= 0) {
 23        this.count = newValue;
 24        this.updateDisplay();
 25      }
 26    };
 27    this.handleInc = () => { 
 28      this.count += this.step;
 29      this.updateDisplay();
 30    };
 31    this.handleReset = () => { 
 32      this.count = this.start; 
 33      this.updateDisplay(); 
 34    };
 35    
 36    this.decBtn.addEventListener('click', this.handleDec);
 37    this.incBtn.addEventListener('click', this.handleInc);
 38    this.resetBtn.addEventListener('click', this.handleReset);
 39  }
 40  
 41  connectedCallback() {
 42    const startVal = parseInt(this.getAttribute('start') || '0');
 43    const stepVal = parseInt(this.getAttribute('step') || '1');
 44    
 45    if (startVal < 0) {
 46      this.errorEl.textContent = 'start must be at least 0';
 47      this.errorEl.style.display = 'block';
 48      this.start = 0;
 49    } else {
 50      this.start = startVal;
 51      this.errorEl.style.display = 'none';
 52    }
 53    
 54    if (stepVal < 1 || stepVal > 10) {
 55      this.errorEl.textContent = 'step must be between 1 and 10';
 56      this.errorEl.style.display = 'block';
 57      this.step = Math.max(1, Math.min(10, stepVal));
 58    } else {
 59      this.step = stepVal;
 60      if (this.start === startVal) {
 61        this.errorEl.style.display = 'none';
 62      }
 63    }
 64    
 65    this.count = this.start;
 66    this.updateDisplay();
 67  }
 68  
 69  disconnectedCallback() {
 70    this.decBtn.removeEventListener('click', this.handleDec);
 71    this.incBtn.removeEventListener('click', this.handleInc);
 72    this.resetBtn.removeEventListener('click', this.handleReset);
 73  }
 74  
 75  attributeChangedCallback(name, oldValue, newValue) {
 76    if (name === 'start') {
 77      const startVal = parseInt(newValue || '0');
 78      if (startVal < 0) {
 79        this.errorEl.textContent = 'start must be at least 0';
 80        this.errorEl.style.display = 'block';
 81        this.start = 0;
 82      } else {
 83        this.start = startVal;
 84        this.errorEl.style.display = 'none';
 85      }
 86      this.count = this.start;
 87    } else if (name === 'step') {
 88      const stepVal = parseInt(newValue || '1');
 89      if (stepVal < 1 || stepVal > 10) {
 90        this.errorEl.textContent = 'step must be between 1 and 10';
 91        this.errorEl.style.display = 'block';
 92        this.step = Math.max(1, Math.min(10, stepVal));
 93      } else {
 94        this.step = stepVal;
 95        this.errorEl.style.display = 'none';
 96      }
 97    }
 98    if (this.isConnected) {
 99      this.updateDisplay();
100    }
101  }
102  
103  updateDisplay() {
104    this.countEl.textContent = this.count;
105  }
106}
107
108customElements.define('simple-counter', SimpleCounter);

Overview #

Rocket allows you to turn HTML templates into fully reactive web components. The backend remains the source of truth, but your frontend components are now encapsulated and reusable without any of the usual hassle.

Add data-rocket:my-component to a template element to turn it into a Rocket component. Component signals are automatically scoped with $$, so component instances don’t interfere with each other.

You can use Rocket to wrap external libraries using module imports, and create references to elements within your component. Each component gets its own signal namespace that plays nicely with Datastar’s global signals. When you remove a component from the DOM, all its $$ signals are cleaned up automatically.

Bridging Web Components and Datastar #

Web components want encapsulation; Datastar wants a global signal store. Rocket gives you both by creating isolated namespaces for each component. Each instance gets its own sandbox that doesn’t mess with other components on the page, or with global signals.

Multiple component instances work seamlessly, each getting its own numbered namespace. You still have access to global signals when you need them, but your component state stays isolated and clean.

Signal Scoping #

Use $$ for component-scoped signals, and $ for global signals. Component signals are automatically cleaned up when you remove the component from the DOM - no memory leaks, no manual cleanup required.

Behind the scenes, your $$count becomes something like $._rocket.my_counter.id1.count, with each instance getting its own id-prefixed namespace. You never have to think about this complexity - just write $$count and Rocket handles the rest.

 1// Your component template writes:
 2<button data-on:click="$$count++">Increment</button>
 3<span data-text="$$count"></span>
 4
 5// Rocket transforms it to (for instance #1):
 6<button data-on:click="$._rocket.my_counter.id1.count++">Increment</button>
 7<span data-text="$._rocket.my_counter.id1.count"></span>
 8
 9// The global Datastar signal structure:
10$._rocket = {
11  my_counter: {
12    id1: { count: 0 }, // First counter instance
13    id2: { count: 5 }, // Second counter instance
14    id3: { count: 10 } // Third counter instance
15  },
16  user_card: {
17    id4: { name: "Alice" }, // Different component type
18    id5: { name: "Bob" }
19  }
20}

Defining Rocket Components #

Rocket components are defined using a HTML template element with the data-rocket:my-component attribute, where my-component is the name of the resulting web component. The name must contain at least one hyphen, as per the custom element specification.

1<template data-rocket:my-counter>
2  <script>
3    $$count = 0  
4  </script>
5  <button data-on:click="$$count++">
6    Count: <span data-text="$$count"></span>
7  </button>
8</template>

This gets compiled to a web component, meaning that usage is simply:

1<my-counter></my-counter>

Rocket components must be defined before being used in the DOM.

1<!-- Template element must appear first in the DOM. -->
2<template data-rocket:my-counter></template>
3
4<my-counter></my-counter>

Signal Management #

Rocket makes it possible to work with both component-scoped and global signals (global to the entire page).

Component Signals #

Component-scoped signals use the $$ prefix and are isolated to each component instance.

 1<template data-rocket:isolated-counter>
 2  <script>
 3    // These are component-scoped – each instance has its own values
 4    $$count = 0
 5    $$step = 1
 6    $$maxCount = 10
 7    $$isAtMax = computed(() => $$count >= $$maxCount)
 8    
 9    // Component actions
10    action({
11      name: 'increment',
12      apply() {
13        if ($$count < $$maxCount) {
14          $$count += $$step
15        }
16      },
17    })
18  </script>
19  
20  <div>
21    <p>Count: <span data-text="$$count"></span></p>
22    <p data-show="$$isAtMax" class="error">Maximum reached!</p>
23    <button data-on:click="@increment()" data-attr:disabled="$$isAtMax">+</button>
24  </div>
25</template>
26
27<!-- Multiple instances work independently -->
28<isolated-counter></isolated-counter>
29<isolated-counter></isolated-counter>

Global Signals #

Global signals use the $ prefix and are shared across the entire page.

 1<template data-rocket:theme-toggle>
 2  <script>
 3    // Access global theme state
 4    if (!$theme) {
 5      $theme = 'light'
 6    }
 7    
 8    action({
 9      name: 'toggleTheme',
10      apply() {
11        $theme = $theme === 'light' ? 'dark' : 'light'
12      },
13    })
14  </script>
15  
16  <button data-on:click="@toggleTheme()">
17    <span data-text="$theme === 'light' ? '🌙' : '☀️'"></span>
18    <span data-text="$theme === 'light' ? 'Dark Mode' : 'Light Mode'"></span>
19  </button>
20</template>
21
22<!-- All instances share the same global theme -->
23<theme-toggle></theme-toggle>
24<theme-toggle></theme-toggle>

Props #

The data-prop:* attribute allows you to define component props with codecs for normalization and defaults. Custom element attributes arrive as strings, then Rocket decodes them into typed values using your schema.

 1<!-- Component definition with defaults -->
 2<template data-rocket:progress-bar
 3          data-prop:value="int"
 4          data-prop:max="int (= 100)" 
 5          data-prop:color="str (= 'blue')"
 6>
 7  <script>
 8    $$percentage = computed(() => Math.round(($$value / $$max) * 100))
 9  </script>
10  
11  <div class="progress-container">
12    <div class="progress-bar" 
13        data-style="{
14          width: $$percentage + '%',
15          backgroundColor: $$color
16        }">
17    </div>
18    <span data-text="$$percentage + '%'"></span>
19  </div>
20</template>
21
22<!-- Usage -->
23<progress-bar data-attr:value="'75'" data-attr:color="'green'"></progress-bar>
24<progress-bar data-attr:value="'30'" data-attr:max="'50'"></progress-bar>

Rocket automatically transforms values using the codecs defined in data-prop:* attributes.

Most component scripts should stay simple: if a prop has a codec, use $$prop directly. Avoid re-parsing with Number(...) or repeating clamp/range checks unless the value comes from external runtime data (events, network payloads, third-party APIs).

Structured Object Props #

1<template data-rocket:leaderboard
2          data-prop:scores="vals (int (clamp 0 100)) (= {alice:85,bob:92})"
3          data-prop:meta="obj (title (str trim)) (active bool)"
4>
5  <script>
6    $$entries = computed(() => Object.entries($$scores))
7    $$title = computed(() => $$meta.title)
8  </script>
9</template>

Use vals valueSchema for dynamic key/value maps, and obj (key schema) ... for fixed object shapes.

In obj, grouped key/value pairs like (title str(trim)) are the recommended form.

Setup Scripts #

Setup scripts initialize component behavior and run when the component is created. Rocket supports both component (per-instance) and static (one-time) setup scripts.

Component Setup Scripts #

Regular <script> tags run for each component instance.

 1<template data-rocket:timer
 2          data-prop:seconds="int"
 3          data-prop:running="bool"
 4          data-prop:interval="int (= 1000)"
 5>
 6  <script>
 7    $$minutes = computed(() => Math.floor($$seconds / 60))
 8    $$displayTime = computed(() => {
 9      const m = String($$minutes).padStart(2, '0')
10      const s = String($$seconds % 60).padStart(2, '0')
11      return m + ':' + s
12    })
13    
14    let intervalId
15    effect(() => {
16      if ($$running) {
17        intervalId = setInterval(() => $$seconds++, $$interval)
18      } else {
19        clearInterval(intervalId)
20      }
21    })
22    
23    // Cleanup when component is removed
24    onCleanup(() => {
25      clearInterval(intervalId)
26    })
27  </script>
28  
29  <div>
30    <h2 data-text="$$displayTime"></h2>
31    <button data-on:click="$$running = !$$running" 
32            data-text="$$running ? 'Stop' : 'Start'">
33    </button>
34    <button data-on:click="$$seconds = 0">Reset</button>
35</div>
36</template>

Host Element Access #

Rocket injects an el binding into every component setup script. It always points to the current custom element instance, even when you opt into Shadow DOM, so you can imperatively read attributes, toggle classes, or wire event listeners.

1<template data-rocket:focus-pill>
2  <script>
3    el.setAttribute('role', 'button')
4    el.addEventListener('focus', () => el.classList.add('is-focused'))
5    el.addEventListener('blur', () => el.classList.remove('is-focused'))
6  </script>
7  
8  <span><slot></slot></span>
9</template>

Setup code executes inside an arrow function sandbox, so this has no meaning inside component scripts. Use el any time you need the host element—for example to call el.shadowRoot, el.setAttribute, or pass it into a third-party library.

Schema and Codec Helpers in Scripts #

Rocket injects schemas and codecs into setup scripts. Use schemas.name(value) to reuse a data-schema:* decoder+default normalization pipeline. Runtime codec helpers are explicit: codecs.name.decode(value, ...params) and codecs.name.encode(value, ...params). As a shorthand, codecs.name(value, ...params) calls decode as that's usually what you want.

 1<template data-rocket:map-zoom
 2          data-schema:zoom-range="num (clamp 0 22) (= 9.5)"
 3          data-prop:zoom="zoomRange"
 4>
 5  <script>
 6    const bumpZoom = (delta) => {
 7      const normalized = codecs.clamp.decode($$zoom + delta, 0, 22)
 8      const attrValue = codecs.clamp.encode(normalized, 0, 22)
 9      el.dataset.zoomAttr = String(attrValue)
10      $$zoom = schemas.zoomRange(normalized)
11    }
12  </script>
13</template>

Static Setup Scripts #

Scripts with a data-static attribute only run once, when the component type is first registered. This is useful for shared constants or utilities.

 1<template data-rocket:icon-button>
 2  <script data-static>
 3    const icons = {
 4      heart: '❤️',
 5      star: '⭐',
 6      thumbs: '👍',
 7      fire: '🔥'
 8    }
 9  </script>
10  
11  <script>
12    $$icon = $$type || 'heart'
13    $$emoji = computed(() => icons[$$icon] || '❓')
14  </script>
15  
16  <button data-on:click="@click()">
17    <span data-text="$$emoji"></span>
18    <span data-text="$$label || 'Click me'"></span>
19  </button>
20</template>

Module Imports #

Rocket allows you to wrap external libraries, loading them before the component initializes and the setup script runs. Use data-import:* for modern ES modules, and add the __iife modifier (data-import:foo__iife) for legacy globals.

ESM Imports #

The data-import:* attribute loads modern ES modules by default.

 1<template data-rocket:qr-generator
 2          data-prop:text="str trim (= 'Hello World')"
 3          data-prop:size="int (clamp 50 1000) (= 200)"
 4          data-import:qr="https://cdn.jsdelivr.net/npm/[email protected]/+esm"
 5>
 6  <script>
 7    $$errorText = ''
 8    
 9    effect(() => {
10      if (!$$text) {
11        $$errorText = 'Text is required'
12        return
13      }
14      if ($$size < 50 || $$size > 1000) {
15        $$errorText = 'Size must be 50-1000px'
16        return
17      }
18
19      if (!$$canvas) {
20        return
21      }
22
23      if (!qr) {
24        $$errorText = 'QR library not loaded'
25        return
26      }
27      
28      try {
29        qr.render({
30          text: $$text,
31          size: $$size
32        }, $$canvas)
33        $$errorText = ''
34      } catch (err) {
35        $$errorText = 'QR generation failed'
36      }
37    })
38  </script>
39  
40  <div data-style="{width: $$size + 'px', height: $$size + 'px'}">
41    <template data-if="!$$errorText">
42      <canvas data-ref="canvas" style="display: block;"></canvas>
43    </template>
44    <template data-else>
45      <div data-text="$$errorText" class="error"></div>
46    </template>
47  </div>
48</template>

IIFE Imports #

Add the __iife modifier for legacy libraries that expose globals. The library must expose a global variable that matches the alias you specify after data-import:.

 1<template data-rocket:chart
 2          data-prop:data="arr num"
 3          data-prop:type="str (= 'line')"
 4          data-import:chart__iife="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.js"
 5>
 6  <script>
 7    let chartInstance
 8    
 9    effect(() => {
10      if (!$$canvas || !chart || !$$data.length) {
11        return
12      }
13
14      if (chartInstance) {
15        chartInstance.destroy()
16      }
17      
18      const ctx = $$canvas.getContext('2d')
19      chartInstance = new chart.Chart(ctx, {
20        type: $$type,
21        data: {
22          datasets: [{
23            data: $$data,
24            backgroundColor: '#3b82f6'
25          }]
26        }
27      })
28    })
29    
30    onCleanup(() => {
31      if (chartInstance) {
32        chartInstance.destroy()
33      }
34    })
35  </script>
36  
37  <canvas data-ref="canvas"></canvas>
38</template>

Rocket Attributes #

In addition to the Rocket-specific data-* attributes defined above, the following attributes are available within Rocket components.

Rocket only transforms Datastar attributes such as data-text, data-on, and data-attr. Custom data-* attributes you add for your own semantics (e.g., data-info="Hello Delaney!") are preserved verbatim in the rendered DOM.

By default, Rocket renders into the light DOM of the custom element, so the component’s content participates directly in the page layout and inherits global styles. The shadow attributes data-shadow-* let's you opt a component into using a Shadow DOM host instead. If you’re not familiar with Shadow DOM concepts like the shadow root, it’s worth reading the MDN documentation first.

Light DOM style scoping #

Light DOM Rocket components automatically scope any <style> blocks declared inside the component template and inside the component’s light DOM children. Selectors are rewritten to target only that component instance, so styles won’t leak across instances. Global stylesheets still apply as usual.

Use :global(...) in a selector to opt out of scoping for that selector. Shadow DOM components already have native style encapsulation, so scoping is only applied to light DOM components.

 1<template data-rocket:badge-list>
 2  <style>
 3    .badge { display: inline-flex; gap: 0.25rem; }
 4    .badge strong { color: #0a0; }
 5    :global(.accent) { color: #e11d48; }
 6  </style>
 7  <div class="badge">
 8    <strong data-text="$$label"></strong>
 9    <slot></slot>
10  </div>
11</template>
12
13<badge-list data-attr:label="'Team'">
14  <style>
15    .badge { background: #fee; border: 1px solid #f99; }
16    .badge em { font-style: normal; color: #900; }
17  </style>
18  <em class="accent">Alpha</em>
19</badge-list>

data-shadow-open #

Use data-shadow-open to force an open Shadow DOM when you want style encapsulation but still need access to internal elements via element.shadowRoot, which is useful during debugging or integration.

 1<template data-rocket:tag-pill
 2          data-shadow-open
 3          data-prop:label="str trim">
 4  <style>
 5    .pill {
 6      display: inline-flex;
 7      align-items: center;
 8      padding: 0.25rem 0.5rem;
 9      border-radius: 999px;
10      background: #0f172a;
11      color: white;
12      font-size: 0.75rem;
13      gap: 0.25rem;
14    }
15    .dot {
16      width: 6px;
17      height: 6px;
18      border-radius: 999px;
19      background: #22c55e;
20    }
21  </style>
22  <div class="pill">
23    <span class="dot"></span>
24    <span data-text="$$label"></span>
25  </div>
26</template>
27
28<!-- Styles are fully encapsulated, but devtools and test harnesses can still inspect the .pill element via element.shadowRoot -->
29<tag-pill data-attr:label="'Shadow-ready'"></tag-pill>

data-shadow-closed #

Use data-shadow-closed to force a closed Shadow DOM. Choose this when you want the implementation to be fully encapsulated and inaccessible via element.shadowRoot, while still benefitting from Shadow DOM styling and slot projection.

 1<template data-rocket:status-tooltip
 2          data-shadow-closed
 3          data-prop:text="str trim">
 4  <script>
 5    $$show = false
 6  </script>
 7
 8  <span data-on:mouseenter="$$show = true"
 9        data-on:mouseleave="$$show = false">
10    <slot></slot>
11    <span data-show="$$show" class="tooltip"
12          data-text="$$text"></span>
13  </span>
14</template>
15
16<!-- The tooltip DOM is hidden inside a closed shadow root -->
17<status-tooltip data-attr:text="'Hello from Rocket'">
18  Hover me
19</status-tooltip>

data-if #

Conditionally outputs an element based on an expression. Must be placed on a <template> element in Rocket components.

1<template data-if="$$items.count">
2  <div data-text="$$items.count + ' items'"></div>
3</template>

data-else-if #

Conditionally outputs an element based on an expression, if the preceding data-if condition is falsy. Must be on a <template>.

1<template data-if="$$items.count">
2  <div data-text="$$items.count + ' items found.'"></div>
3</template>
4<template data-else-if="$$items.count == 1">
5  <div data-text="$$items.count + ' item found.'"></div>
6</template>

data-else #

Outputs an element if the preceding data-if and data-else-if conditions are falsy. Must be on a <template>.

1<template data-if="$$items.count">
2  <div data-text="$$items.count + ' items found.'"></div>
3</template>
4<template data-else>
5  <div>No items found.</div>
6</template>

data-for #

Loops over any iterable (arrays, maps, sets, strings, and plain objects), and outputs the element for each item. Must be placed on a <template>.

1<template data-for="item, index in $$items">
2  <div>
3    <span data-text="index + ': ' + item.name"></span>
4  </div>
5</template>

data-key #

Provides a stable key for each iteration when used alongside data-for. Keys enable DOM reuse (Solid-like keyed loops) and must live on the same <template data-for>.

1<template data-for="item in $$items" data-key="item.id">
2  <div data-text="item.label"></div>
3</template>

The first alias (item above) is available to descendants just like any other binding. An optional second alias (index above) exposes the current key or numeric index. Nested loops are supported, and inner loop variables automatically shadow outer ones, so you can reuse names without conflicts.

1<template data-for="items in $$itemSet">
2  <div>
3    <template data-for="item in items">
4      <div>
5        <span data-text="item.name"></span>
6      </div>
7    </template>
8  </div>
9</template>

Reactive Patterns #

Rocket provides computed and effect functions for declarative reactivity. These keep your component state automatically in sync with the DOM.

Computed Values #

Computed values automatically update when their dependencies change.

 1<template data-rocket:shopping-cart
 2          data-prop:items="arr (obj
 3            (name (str (trim)))
 4            (price (num (clamp 0 1000000)))
 5            (quantity (int (clamp 1 99)))
 6          )"
 7>
 8  <script>
 9    // Computed values automatically recalculate
10    $$total = computed(() => 
11      $$items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
12    )
13    
14    $$itemCount = computed(() =>
15      $$items.reduce((sum, item) => sum + item.quantity, 0)
16    )
17    
18    $$isEmpty = computed(() => $$items.length === 0)
19    
20    // Actions that modify reactive state
21    action({
22      name: 'addItem',
23      apply(_, item) {
24        $$items = [...$$items, { ...item, quantity: 1 }]
25      },
26    })
27    
28    action({
29      name: 'removeItem',
30      apply(_, index) {
31        $$items = $$items.filter((_, i) => i !== index)
32      },
33    })
34  </script>
35  
36  <div>
37    <h3>Shopping Cart</h3>
38    <p data-show="$$isEmpty">Cart is empty</p>
39    <p data-show="!$$isEmpty">
40      Items: <span data-text="$$itemCount"></span> | 
41      Total: $<span data-text="$$total.toFixed(2)"></span>
42    </p>
43    
44    <template data-for="item, index in $$items">
45      <div>
46        <span data-text="item.name"></span> - 
47        <span data-text="'$' + item.price"></span>
48        <button data-on:click="@removeItem(index)">Remove</button>
49      </div>
50    </template>
51  </div>
52</template>

Effects and Watchers #

Effects run side effects when reactive values change.

 1<template data-rocket:auto-saver
 2          data-prop:data="str"
 3          data-prop:last-saved="str"
 4          data-prop:saving="bool"
 5>
 6  <script>
 7    let saveTimeout
 8    
 9    // Auto-save effect
10    effect(() => {
11      if (!$$data) {
12        return
13      }
14      
15      clearTimeout(saveTimeout)
16      saveTimeout = setTimeout(async () => {
17        $$saving = true
18        try {
19          await actions.post('/api/save', { data: $$data })
20          $$lastSaved = new Date().toLocaleTimeString()
21        } catch (error) {
22          console.error('Save failed:', error)
23        } finally {
24          $$saving = false
25        }
26      }, 1000) // Debounce by 1 second
27    })
28    
29    // Theme effect
30    effect(() => {
31      if ($theme) {
32        document.body.className = $theme + '-theme'
33      }
34    })
35    
36    onCleanup(() => {
37      clearTimeout(saveTimeout)
38    })
39  </script>
40  
41  <div>
42    <textarea data-bind="data" placeholder="Start typing..."></textarea>
43    <p data-show="$$saving">Saving...</p>
44    <p data-show="$$lastSaved">Last saved: <span data-text="$$lastSaved"></span></p>
45  </div>
46</template>

Element References #

You can use data-ref to create references to elements within your component. Element references are available as $$elementName signals and automatically updated when the DOM changes.

 1<template data-rocket:canvas-painter
 2          data-prop:color="str (= '#000000')"
 3          data-prop:brush-size="int (= 5)"
 4>
 5  <script>
 6    let ctx
 7    let isDrawing = false
 8    
 9    // Get canvas context when canvas is available
10    effect(() => {
11      if ($$canvas) {
12        ctx = $$canvas.getContext('2d')
13        ctx.strokeStyle = $$color
14        ctx.lineWidth = $$brushSize
15        ctx.lineCap = 'round'
16      }
17    })
18    
19    // Update drawing properties
20    effect(() => {
21      if (ctx) {
22        ctx.strokeStyle = $$color
23        ctx.lineWidth = $$brushSize
24      }
25    })
26    
27    action({
28      name: 'startDrawing',
29      apply(_, e) {
30        isDrawing = true
31        const rect = $$canvas.getBoundingClientRect()
32        ctx.beginPath()
33        ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top)
34      },
35    })
36    
37    action({
38      name: 'draw',
39      apply(_, e) {
40        if (!isDrawing) {
41          return
42        }
43
44        const rect = $$canvas.getBoundingClientRect()
45        ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top)
46        ctx.stroke()
47      },
48    })
49    
50    action({
51      name: 'stopDrawing',
52      apply() {
53        isDrawing = false
54      },
55    })
56    
57    action({
58      name: 'clear',
59      apply() {
60        if (ctx) {
61          ctx.clearRect(0, 0, $$canvas.width, $$canvas.height)
62        }
63      },
64    })
65  </script>
66  
67  <div>
68    <div>
69      <label>Color: <input type="color" data-bind="color"></label>
70      <label>Size: <input type="range" min="1" max="20" data-bind="brushSize"></label>
71      <button data-on:click="@clear()">Clear</button>
72    </div>
73    
74    <canvas 
75      data-ref="canvas" 
76      width="400" 
77      height="300"
78      style="border: 1px solid #ccc"
79      data-on:mousedown="@startDrawing"
80      data-on:mousemove="@draw"
81      data-on:mouseup="@stopDrawing"
82      data-on:mouseleave="@stopDrawing">
83    </canvas>
84  </div>
85</template>

Codecs and Normalization #

Rocket codecs normalize incoming prop values into component-ready data. Rocket does not provide built-in validation signals, so render any error UI in your own setup logic.

Type Codecs #

Type codecs convert attribute strings into typed values, while transform codecs normalize those decoded values.

 1<template data-rocket:normalized-form
 2          data-prop:email="str trim lower"
 3          data-prop:age="int (clamp 18 120)"
 4          data-prop:score="int (clamp 0 100)"
 5>
 6  <script>
 7    $$emailLooksValid = computed(() => $$email.includes('@'))
 8    $$canSubmit = computed(() => $$emailLooksValid && $$age >= 18)
 9  </script>
10  
11  <form>
12    <div>
13      <label>Email:</label>
14      <input type="email" data-bind="email">
15      <span data-show="!$$emailLooksValid" class="error">Email must include @</span>
16    </div>
17    
18    <div>
19      <label>Age (18-120):</label>
20      <input type="number" data-bind="age">
21    </div>
22    
23    <div>
24      <label>Score (0-100, auto-clamped):</label>
25      <input type="number" data-bind="score">
26      <span>Current: <span data-text="$$score"></span></span>
27    </div>
28    
29    <button type="submit" data-attr:disabled="!$$canSubmit">
30      Submit
31    </button>
32  </form>
33</template>

Schema Composition #

Combine schemas to model nested data. arr supports both homogeneous arrays and tuple-like positional schemas, obj defines fixed keys, vals normalizes dynamic maps, and or selects one branch from schemas or literal values (strings, numbers, booleans, null, undefined).

 1<template data-rocket:search-state
 2          data-prop:view="str trim lower (= 'list')"
 3          data-prop:page-size="int (clamp 10 50) (= 25)"
 4                  data-prop:position="arr
 5                    (num (clamp -180 180))
 6                        (num (clamp -90 90)) (= [-115,36])"
 7                  data-prop:filters='vals bool (= {archived:false})'
 8          data-prop:profile="obj (name (str trim)) (role (str upper))"
 9          data-prop:mixed="or (int (clamp 0 1)) (str trim) (= 0)"
10          data-prop:error-level="or (L M Q H) (= L)"
11          data-prop:priority="or (1 2 3) (= 2)"
12>
13  <script>
14    $$isCompact = computed(() => $$pageSize <= 25)
15  </script>
16</template>

Array vs Tuple Parsing #

arr has two modes: one schema argument means "array of items," while multiple schema arguments means "fixed tuple." When the item schema is itself a grouped call, wrap it in an extra set of parentheses so outer arr still sees exactly one argument.

 1<!-- Array/list mode -->
 2data-prop:tags="arr str"
 3
 4<!-- Tuple mode (fixed positions: [lng, lat]) -->
 5data-prop:center="arr (num (clamp -180 180)) (num (clamp -90 90))"
 6
 7<!-- Array of tuple items (extra parens keep outer arr in list mode) -->
 8data-prop:markers="arr ((arr lngLat (str) (str (trim) (= 'mdi:map-marker'))))"
 9
10<!-- Optional readability pattern: name the tuple schema -->
11data-schema:marker="arr lngLat (str) (str (trim) (= 'mdi:map-marker'))"
12data-prop:markers="arr marker"

Optional Object Keys #

obj does not support optional keys. Every declared key is always present in decoded output. If input omits a key, Rocket fills it from that key schema default.

 1<!-- "nickname" behaves optional, but key is always present -->
 2<template data-rocket:user-card
 3          data-prop:profile='obj (name (str trim)) (nickname str (= ""))'
 4>
 5  <script>
 6    // Always true for obj(): key exists, value may be empty/default
 7    $$hasNickname = computed(() => $$profile.nickname.length > 0)
 8  </script>
 9</template>
10
11<!-- Presence/absence semantics: use vals() instead -->
12<template data-rocket:user-flags
13                  data-prop:flags='vals bool (= {beta:false})'
14>
15  <script>
16    $$hasBetaFlag = computed(() => 'beta' in $$flags)
17  </script>
18</template>

For date props, omitting an explicit default will use the current time. This is evaluated when the codec runs, producing a fresh Date instance based on the current time.

1<template data-rocket:last-updated
2          data-prop:serverUpdateTime="date"
3>
4            <script>
5    $$formatted = computed(() => $$serverUpdateTime.toLocaleString())
6        </script>
7  
8        <span data-text="$$formatted"></span>
9</template>

Codec Reference #

Codecs normalize values. If you need validation rules, localization, or error rendering, implement those in your own component script and templates.

CodecBehavior
Type Conversion
strConverts to string.
intConverts to integer.
numConverts to number.
dateConverts ISO strings or timestamps to a Date object (defaults to current time).
boolConverts to boolean. Empty present attributes decode as true.
jsonParses JSON or falls back to an empty object.
jsParses JS object literals.
⚠️ Avoid client values
binDecodes string data into a Uint8Array.
arr schemaA [schemaB ...]Parses arrays: one schema means list items, multiple schemas mean fixed tuple positions. If the item schema is grouped, wrap it so outer arr still has one argument (for example arr ((arr ...))).
vals schemaParses/normalizes object maps and applies one value schema per key.
obj (key schema) ...Builds a fixed object shape with known keys and per-key schemas.
or schemaA schemaB ...Tries branches left-to-right. Branches can be schemas or literals (or (a b) (= a), or (1 2 3) (= 2)).
String Transforms
trimRemoves leading/trailing whitespace.
upper / lowerConverts casing.
kebab / camel / snake / pascal / titleConverts naming style.
maxLength nTruncates strings to n characters.
prefix text / suffix textAdds a missing prefix or suffix.
Numeric Transforms
min n / max n / clamp min maxConstrains numeric values.
step(step base)Snaps numeric values to a step interval.
round n / ceil n / floor nRounds numeric values.
lerp(min max) / inverseLerp(min max) / fit(...)Maps values across numeric ranges.

Component Lifecycle #

Rocket components have a simple lifecycle with automatic cleanup.

 1<template data-rocket:lifecycle-demo>
 2  <script>
 3    console.log('Component initializing...')
 4    
 5    $$mounted = true
 6    
 7    // Setup effects and timers
 8    const intervalId = setInterval(() => {
 9      console.log('Component is alive')
10    }, 5000)
11    
12    // Cleanup when component is removed from DOM
13    onCleanup(() => {
14      console.log('Component cleanup')
15      clearInterval(intervalId)
16      $$mounted = false
17    })
18  </script>
19  
20  <div>
21    <p data-show="$$mounted">Component is mounted</p>
22  </div>
23</template>

The lifecycle is as follows:

  1. Rocket processes your template and registers the component.
  2. When you add it to the DOM, the instance is created and setup scripts run to initialize your signals.
  3. The component becomes reactive and responds to data changes.
  4. When you remove it from the DOM, all onCleanup callbacks run automatically.

Optimistic UI #

Rocket pairs seamlessly with Datastar’s server-driven model to provide instant visual feedback without shifting ownership of state to the browser. In the Rocket flow example, dragging a node instantly renders its optimistic position in the SVG while the original light-DOM host remains hidden. The component adds an .is-pending class to dim the node and connected edges, signaling that the drag is provisional. Once the backend confirms the new coordinates and updates the layout, the component automatically clears the pending style.

A dedicated prop such as server-update-time="date" makes this straightforward: each tab receives an updated timestamp from the server (via SSE or a patch), Rocket decodes it into a Date (defaulting to the current time when no value is provided), and internal effects react to reconcile every view. Unlike client-owned graph editors (e.g. React Flow), the server stays the single source of truth, while the optimistic UI remains a thin layer inside the component.

Examples #

Check out the Copy Button as a basic example, the QR Code generator with manual error handling, the ECharts integration for data visualization, the interactive 3D Globe with markers, and the Virtual Scroll example for handling large datasets efficiently.