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 theseCellUI's ambient() is flat, not nested. There's no provider tree to manage.