Composability

Components Are Functions

In React, components are functions that re-execute on every render. In Vue, they're objects with a template, script, and style section. In CellUI, a component is a function that returns DOM nodes. It runs once:

typescriptfunction Greeting(name: string) {
  return view`<h1>Hello, ${name}</h1>`;
}

function App() {
  return view`
    <div>
      ${Greeting('Alice')}
      ${Greeting('Bob')}
    </div>
  `;
}

There's no component registration, no props interface, no hooks rules. A component is a function call that returns a DocumentFragment. Composition is calling functions.

Passing signals

If you want a child component to be reactive, pass signals:

typescriptfunction Counter(count: Signal<number>) {
  return view`
    <div>
      <span>${count}</span>
      <button onclick="${() => count.value++}">+</button>
    </div>
  `;
}

function App() {
  const count = signal(0);
  return view`
    <h1>Counter Demo</h1>
    ${Counter(count)}
    <p>Total across app: ${count}</p>
  `;
}

Both Counter and App's <p> tag bind to the same signal. When the button is clicked, both update. No prop drilling, no context, no store — just passing a reference.

Returning multiple elements

A component can return a DocumentFragment with multiple root elements:

typescriptfunction TableRow(item: { name: string; age: number }) {
  return view`
    <td>${item.name}</td>
    <td>${item.age}</td>
  `;
}

This is valid because view always returns a DocumentFragment, which can hold multiple children.

Shared State

ambient() — global singletons

For state that's truly global (theme, auth, locale), use ambient():

typescriptimport { ambient } from '@cmj/cellui';

// Creates a global signal, or returns the existing one with this key
const theme = ambient('theme', 'dark');

// In any component, anywhere:
function ThemeToggle() {
  const t = ambient<string>('theme');
  return view`
    <button onclick="${() => t.value = t.value === 'dark' ? 'light' : 'dark'}">
      Theme: ${t}
    </button>
  `;
}

ambient() is a global registry. Same key = same signal. No providers, no context wrappers, no prop drilling. Call it from any function and get the same reactive reference.

When to use: App-wide settings that any component might need. Theme, locale, user auth state.

When NOT to use: Component-local state, or state that multiple instances need independently. Two <Counter> components should NOT share the same count signal via ambient.

createSubstrate() — scoped dependency injection

For state scoped to a section of your app:

typescriptimport { createSubstrate } from '@cmj/cellui';

function Dashboard() {
  const store = createSubstrate('dashboard');
  const metrics = store.connect('metrics', signal([]));
  const filter = store.connect('filter', signal('all'));

  return view`
    <div>
      ${FilterBar(store)}
      ${MetricsList(store)}
    </div>
  `;
}

function FilterBar(store: Substrate) {
  const filter = store.connect<string>('filter');
  // Same signal — connected by key
  return view`
    <select ${bind(filter)}>
      <option value="all">All</option>
      <option value="active">Active</option>
    </select>
  `;
}

createSubstrate creates a named scope. connect(key, initialValue?) returns a signal — creating it if new, returning the existing one if already connected. This gives you dependency injection without providers.

The global: prefix delegates to ambient():

typescriptconst store = createSubstrate('myFeature');
const theme = store.connect('global:theme', 'dark');
// Same signal as ambient('theme', 'dark')

When to use: Feature-scoped state shared between multiple components in a section of the app. A dashboard's metrics, a form wizard's step state.

When NOT to use: Simple parent-child state passing (just pass the signal directly). Or truly global state (use ambient() — it's simpler).

destroy()

Clean up a substrate when the feature unmounts:

typescriptconst store = createSubstrate('dashboard');

CellUI.onDispose(container, () => {
  store.destroy(); // clears all connected signals
});

Patterns to Avoid

Don't wrap signals in objects for "stores"

typescript// BAD: unnecessary abstraction
function createCounterStore() {
  const count = signal(0);
  return {
    count,
    increment: () => count.value++,
    decrement: () => count.value--,
  };
}

// GOOD: just use signals directly
const count = signal(0);
// increment is: count.value++
// decrement is: count.value--

Signals ARE the store. Wrapping them in an object adds indirection without benefit. If you need shared logic, write a function that takes signals as arguments.

Don't create "hooks"

typescript// BAD: React muscle memory
function useCounter(initial = 0) {
  const count = signal(initial);
  return { count, increment: () => count.value++ };
}

// GOOD: CellUI components run once — just declare the signal
function Counter(initial = 0) {
  const count = signal(initial);
  return view`
    <button onclick="${() => count.value++}">${count}</button>
  `;
}

There are no hooks rules in CellUI because there are no hooks. Components run once, so you can declare signals anywhere — in loops, in conditionals, in callbacks. The "rules of hooks" problem doesn't exist.

Don't nest providers

typescript// BAD: React context pattern
function App() {
  return view`
    <ThemeProvider>
      <AuthProvider>
        <RouterProvider>
          ${ActualContent()}
        </RouterProvider>
      </AuthProvider>
    </ThemeProvider>
  `;
}

// GOOD: just use ambient()
const theme = ambient('theme', 'dark');
const user = ambient('user', null);
// No wrapping needed — any component can access these

CellUI's ambient() is flat, not nested. There's no provider tree to manage.