berhalak

grainjs

0
0
# Install this skill:
npx skills add berhalak/skills --skill "grainjs"

Install specific skill from multi-skill repository

# Description

Use when building UI with grainjs β€” creating DOM elements, using Observables/Computed, conditional rendering with dom.maybe/dom.domComputed, styled components, class bindings, event handling, disposal, MultiHolder, obsArray, or any reactive UI pattern using grainjs.

# SKILL.md


name: grainjs
description: Use when building UI with grainjs β€” creating DOM elements, using Observables/Computed, conditional rendering with dom.maybe/dom.domComputed, styled components, class bindings, event handling, disposal, MultiHolder, obsArray, or any reactive UI pattern using grainjs.


GrainJS Reference

GrainJS is a TypeScript library for building reactive UIs with observables and direct DOM construction. No virtual DOM, no JSX β€” just functions that create and bind real DOM elements.


DOM Construction

The dom() function creates elements. Arguments can be child nodes, strings (text nodes), attribute objects, arrays (flattened), functions (called with element), or dom methods.

import { dom, styled, Observable, Computed, Disposable, MultiHolder } from 'grainjs';

dom('a', { href: 'https://example.com' },
  dom.cls('biglink'),
  'Hello ', dom('span', 'world')
);
// <a href="https://example.com" class="biglink">Hello <span>world</span></a>

dom.update(existingElement, ...args) adds children/bindings to an existing element (e.g. document.body).


Observables

Create reactive state containers. First arg is the owner (for automatic disposal), second is the initial value.

const show = Observable.create(owner, false);
show.get();        // false
show.set(true);    // updates value, notifies listeners

// Listen to changes
const listener = show.addListener(val => console.log(val));
listener.dispose(); // stop listening

Observable as owner of disposable values

Observables can own disposable objects β€” when the observable value changes or the observable is disposed, the old value is disposed too:

const obs = Observable.create<MyClass|null>(owner, null);
MyClass.create(obs, ...args);  // obs owns this instance
obs.set(null);                 // previous MyClass instance is disposed

Computed Observables

Derived values that auto-update when dependencies change. The use() function reads an observable's value AND registers it as a dependency.

const obs1 = Observable.create(owner, 5);
const obs2 = Observable.create(owner, 12);

// Dynamic dependencies via use()
const sum = Computed.create(owner, use => use(obs1) + use(obs2));

// Explicit dependencies (slightly more efficient)
const sum2 = Computed.create(owner, obs1, obs2,
  (use, v1, v2) => v1 + v2);

Evaluation Order

GrainJS recalculates computeds in dependency order. If total depends on tax and tip, which both depend on amount, changing amount recalculates tax, then tip, then total.

bundleChanges

Defer computed recalculation until multiple observables are updated:

import { bundleChanges } from 'grainjs';

bundleChanges(() => {
  taxRate.set(0.0875);
  tipRate.set(0.20);
});
// Computeds recalculate only once after both changes

subscribe()

Subscribe to multiple observables. Callback fires immediately and on every change:

subscribe(obs1, obs2, (use, v1, v2) => console.log(v1, v2));
// or dynamically:
subscribe(use => console.log(use(obs1), use(obs2)));

DOM Bindings with Observables

Dynamic attributes, classes, text

const isBig = Observable.create(null, true);
const href = Observable.create(null, 'https://example.com');
const name = Observable.create(null, 'world');

dom('a',
  dom.cls('biglink', isBig),           // class toggled by observable
  dom.attr('href', href),              // attribute bound to observable
  'Hello ', dom('span', dom.text(name)) // text bound to observable
);

isBig.set(false);       // removes 'biglink' class
href.set('about:blank'); // changes href
name.set('Bart');        // changes text

Computed callbacks in bindings

Pass a use => callback anywhere an observable is accepted β€” it auto-creates a Computed:

dom('a',
  dom.cls('small-link', use => !use(isBig)),
);

dom.hide()

dom('div', dom.hide(isHiddenObs));

Class Bindings

dom.cls()

Toggle a single CSS class based on an observable boolean:

dom.cls('active', isActiveObs)        // adds/removes 'active'
dom.cls('highlight', use => use(a) > 5) // computed condition

Styled component .cls() modifier

For styled components, use the .cls('-modifier') pattern:

const cssButton = styled('button', `
  border-radius: 0.5rem;
  font-size: 1rem;
  &-small { font-size: 0.6rem; }
  &-primary { background: blue; color: white; }
`);

// Apply modifier class
cssButton(cssButton.cls('-small'), 'Small Button');

// Toggle modifier with observable
cssButton(cssButton.cls('-primary', isPrimaryObs), 'Button');

Conditional Rendering: dom.maybe()

Renders content when observable is truthy, removes it when falsy. Content is fully disposed and rebuilt on each toggle.

dom('div',
  dom.maybe(isChangedObs, () =>
    dom('button', 'Save')
  )
);

Works with dom.create() for class components:

dom.maybe(show, () => dom.create(TempCalculator));

Key behavior: The callback is called each time the value becomes truthy. When it becomes falsy, the DOM is removed and disposed.


Dynamic DOM: dom.domComputed()

Switches between different DOM structures based on an observable value. The previous DOM is disposed before the new one is created.

dom('div',
  dom.domComputed(isChangedObs, (isChanged) =>
    isChanged
      ? [dom('button', 'Save'), dom('button', 'Revert')]
      : dom('button', 'Close')
  )
);

The callback receives the plain value (not the observable) and can return a single element, an array of elements, or null.

dom.maybe vs dom.domComputed:
- dom.maybe(obs, () => content) β€” show/hide based on truthiness
- dom.domComputed(obs, val => content) β€” rebuild DOM whenever value changes, receiving the actual value


Lists: dom.forEach()

Static arrays

const items = ['Apples', 'Pears', 'Peaches'];
dom('ul', items.map(item => dom('li', item)));

Observable arrays

const items = Observable.create(null, ['Apples', 'Pears']);
dom('ul',
  dom.forEach(items, item => dom('li', item))
);
items.set(['Bananas']); // removes old, inserts new

obsArray for incremental changes

import { obsArray } from 'grainjs';

const items = obsArray(['Apples', 'Pears']);
dom('ul',
  dom.forEach(items, item => dom('li', item))
);
items.push('Peaches');           // appends one <li>
items.splice(1, 1, 'Bananas');   // replaces Pears with Bananas

The per-item callback must return a single DOM element or null (not an array).


Styled Components

styled() generates unique CSS class names and injects styles into the document. Call at module top level (import time).

const cssTitle = styled('h1', `
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`);

const cssWrapper = styled('section', `
  padding: 0.5em 4em;
  background: papayawhip;
`);

dom.update(document.body,
  cssWrapper(cssTitle('Hello world'))
);

Extending styles

const cssTitle2 = styled(cssTitle, `font-size: 1rem; color: red;`);

Nested selectors with &

The & represents the generated class name. Nested styles MUST appear after main styles:

const cssButton = styled('button', `
  border-radius: 0.5rem;
  border: 1px solid grey;
  font-size: 1rem;

  &:active {
    background: lightblue;
  }
  &-small {
    font-size: 0.6rem;
  }
  @media print {
    & { display: none; }
  }
`);

.cls() helper for modifier classes

cssButton(cssButton.cls('-small'), 'Test');
cssButton(cssButton.cls('-small', isSmallObs), 'Test'); // observable toggle

keyframes

import { keyframes } from 'grainjs';

const rotate360 = keyframes(`
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
`);

const cssSpinner = styled('div', `
  animation: ${rotate360} 2s linear infinite;
`);

Convention

Prefix styled component variables with css (e.g., cssButton, cssWrapper). Place them at module bottom.


Disposal & Ownership

Disposable base class

class MyChart extends Disposable {
  constructor() {
    const onResize = () => this._updateChartSize();
    window.addEventListener('resize', onResize);
    this.onDispose(() => window.removeEventListener('resize', onResize));
  }
}

.create() pattern (always preferred over new)

const chart = MyChart.create(owner, ...args);

The owner disposes the object automatically. .create() also handles constructor exceptions safely.

Holder β€” single replaceable slot

this._holder = Holder.create(this);
Bar.create(this._holder, 1);  // held
Bar.create(this._holder, 2);  // disposes previous Bar, holds new one
this._holder.clear();         // disposes contained object

MultiHolder β€” multiple owned objects

this._mholder = MultiHolder.create(this);
Bar.create(this._mholder, 1); // added
Bar.create(this._mholder, 2); // added (both kept)
// all disposed when _mholder is disposed

Use MultiHolder when you need to own multiple disposable objects in a container that isn't itself a Disposable class.

DOM disposal

dom('div',
  dom.autoDispose(someComputed),   // disposed when element is removed
  dom.onDispose(() => cleanup()),  // callback when element is removed
);

dom.maybe(), dom.domComputed(), and dom.forEach() automatically dispose removed DOM and associated resources.


DOM Components

Class components

Extend Disposable, implement buildDom(). Use dom.create() to instantiate:

class TempCalculator extends Disposable {
  private _celsius = Observable.create(this, 25);
  private _fahrenheit = Computed.create(this, use => (use(this._celsius) * 9 / 5) + 32);

  public buildDom() {
    return dom('div',
      dom('input', { type: 'text', value: '25' },
        dom.on('input', (ev, elem) => this._celsius.set(parseFloat(elem.value)))
      ),
      dom('p', dom.text(use => `${use(this._fahrenheit)}F`)),
    );
  }
}

dom.update(document.body, dom.create(TempCalculator));

Functional components

Receive an owner as first param:

function tempCalculator(owner: MultiHolder, initialValue: number) {
  const celsius = Observable.create(owner, initialValue);
  const fahrenheit = Computed.create(owner, use => (use(celsius) * 9 / 5) + 32);

  return dom('div',
    dom('input', { type: 'text', value: String(initialValue) },
      dom.on('input', (ev, elem) => celsius.set(parseFloat(elem.value)))
    ),
    dom('p', dom.text(use => `${use(fahrenheit)}F`)),
  );
}

dom.update(document.body, dom.create(tempCalculator, 30));

Event Handling

dom('button', 'Click',
  dom.on('click', (event, elem) => { ... }),
  dom.on('focus', (event, elem) => { ... }),
);

Keyboard events

dom('input', { type: 'text' },
  dom.onKeyDown({
    Enter: (ev, elem) => submit(),
    Escape: (ev, elem) => cancel(),
    ArrowLeft$: (ev, elem) => { ... }, // $ suffix = don't stop propagation
  })
);

Delegated events

dom.onMatch('.some-selector', 'click', (event, elem) => { ... });

Event Emitters

Separate Emitter instances per event type (no string-based event names):

const cartItemAdded = new Emitter();
const cartItemRemoved = new Emitter();

cartItemAdded.addListener(item => console.log('Added:', item));
cartItemAdded.emit(newItem);

Quick Patterns

Task Pattern
Create observable Observable.create(owner, initialValue)
Create computed Computed.create(owner, use => use(obs1) + use(obs2))
Toggle class dom.cls('name', boolObs)
Bind text dom.text(obs) or dom.text(use => ...)
Bind attribute dom.attr('name', obs)
Show/hide content dom.maybe(obs, () => dom(...))
Switch content dom.domComputed(obs, val => dom(...))
Repeat items dom.forEach(obsArray, item => dom(...))
Style component const cssX = styled('div', \...`)`
Modifier class cssX(cssX.cls('-mod'), ...)
Extend style styled(cssBase, \...`)`
Class component class X extends Disposable { buildDom() {} }
Use component dom.create(ComponentClass, ...args)
Own multiple MultiHolder.create(owner)
Replaceable slot Holder.create(owner)
Dispose on detach dom.autoDispose(disposable)

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.