nuclo
v0.2.31
Published
Lightweight, type-safe DOM library with plain mutable state and explicit updates — global tag builders, SSR, hydration, and typed CSS, no virtual DOM, no compiler.
Maintainers
Readme
nuclo
A lightweight, type-safe JS/TS DOM framework with plain mutable state and explicit updates.
Create elements with plain functions, and pass a function anywhere a value depends on state — that function is registered so Nuclo can evaluate it again later. Change regular JavaScript values directly, then call update() when you want Nuclo to synchronize the DOM. No virtual DOM, no proxies, no signals, and no automatic state tracking.
Nuclo's workflow has three parts:
- Create the UI with builder functions — text, attributes, conditions, and lists can be passed a function whenever they depend on state; this registers the function as a state-dependent value.
- Mutate regular JavaScript state — no signals, no proxies, no wrapped values.
count++is justcount++. Mutating state does not, by itself, touch the DOM. - Call
update()— this explicitly starts synchronization: Nuclo reevaluates the state-dependent values registered in step 1 and applies changed results to the real DOM.
import 'nuclo';
let count = 0;
const counter = div(
h1(() => `Count: ${count}`),
button('Increment', { onClick: () => {
count++;
update();
} })
);
render(counter, document.body);Why nuclo?
- State-Dependent Values – Pass a function for any text, attribute, condition, or list that depends on state; Nuclo can evaluate it again later
- Explicit and Predictable – Reevaluation doesn't happen on mutation — you trigger it yourself with a simple
update()call - Real DOM, No Virtual Layer – Nuclo creates and updates real DOM nodes directly; there's no virtual DOM to diff
- Tiny Footprint – ~13 KB gzipped, zero dependencies
- Global Tag Builders – Natural API with global functions for all HTML and SVG elements
- TypeScript-First – Full type definitions for all 175 HTML and SVG builders
- Targeted DOM Updates –
update()re-runs registered state-dependent values and only touches DOM where values changed - Typed Styling – Built-in
css()/createCss()with TypeScript autocomplete, theming, and SSR CSS collection - Server-Side Rendering –
renderToString()+hydrate()with a lightweight DOM polyfill
Quick Start
Scaffold a new Nuclo + Vite project with a single command:
# npm
npm create nuclo@latest
# pnpm
pnpm create nuclo
# yarn
yarn create nuclo
# bun
bun create nuclo
# deno
deno run -A npm:create-nucloThen follow the prompts for a project name and template, or skip them:
npm create nuclo@latest my-app -- --template basic --yescd my-app
npm install
npm run devInstallation
Already have a project and just want to add Nuclo to it?
npm install nucloUsage
Simply import once to register all global functions:
import 'nuclo';
// Now use div(), update(), on(), list(), when(), render(), etc. globally
let count = 0;
const app = div(
h1(() => `Count: ${count}`),
button('Click', { onClick: () => { count++; update(); } })
);
render(app, document.body);TypeScript Setup
Add to your tsconfig.json:
{
"compilerOptions": {
"types": ["nuclo/types"]
}
}Or in your vite-env.d.ts:
/// <reference types="nuclo/types" />Quick Examples
Counter
import 'nuclo';
let count = 0;
const app = div(
h1(() => `Count: ${count}`),
button('Increment', { onClick: () => { count++; update(); } }),
button('Reset', { onClick: () => { count = 0; update(); } })
);
render(app, document.body);Todo List
import 'nuclo';
type Todo = { id: number; text: string; done: boolean };
let todos: Todo[] = [];
let nextId = 1;
let inputValue = '';
function addTodo() {
if (!inputValue.trim()) return;
todos.push({ id: nextId++, text: inputValue, done: false });
inputValue = '';
update();
}
const app = div(
{ className: 'todo-app' },
// Input
div(
input({ value: () => inputValue },
on('input', e => { inputValue = e.target.value; update(); }),
on('keydown', e => e.key === 'Enter' && addTodo())
),
button('Add', { onClick: addTodo })
),
// List
when(() => todos.length > 0,
list(() => todos, (todo) =>
div(
{ className: () => todo.done ? 'done' : '' },
input({ type: 'checkbox', checked: () => todo.done },
on('change', () => { todo.done = !todo.done; update(); })
),
span(() => todo.text),
button('×', { onClick: () => {
todos = todos.filter(t => t.id !== todo.id);
update();
} })
)
)
).else(
p('No todos yet!')
)
);
render(app, document.body);Real-time Search Filter
import 'nuclo';
const users = [
{ id: 1, name: 'Alice Johnson', email: '[email protected]' },
{ id: 2, name: 'Bob Smith', email: '[email protected]' },
{ id: 3, name: 'Charlie Brown', email: '[email protected]' }
];
let searchQuery = '';
function filteredUsers() {
const q = searchQuery.toLowerCase();
return users.filter(u =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q)
);
}
const app = div(
h1('User Directory'),
input(
{
type: 'search',
placeholder: 'Search users...',
value: () => searchQuery
},
on('input', e => {
searchQuery = e.target.value;
update();
})
),
when(() => filteredUsers().length > 0,
list(() => filteredUsers(), user =>
div(
{ className: 'user-card' },
h3(user.name),
p(user.email)
)
)
).else(
p(() => `No users found for "${searchQuery}"`)
)
);
render(app, document.body);Loading States & Async
import 'nuclo';
type Product = { id: number; title: string; category: string };
type State = { status: 'idle' | 'loading' | 'error'; products: Product[]; error?: string };
const state: State = { status: 'idle', products: [] };
let searchQuery = 'phone';
async function fetchProducts() {
if (!searchQuery.trim()) return;
state.status = 'loading';
update();
try {
const response = await fetch(`https://dummyjson.com/products/search?q=${searchQuery}`);
const data = await response.json();
state.products = data.products;
state.status = 'idle';
} catch (err) {
state.status = 'error';
state.error = err.message;
}
update();
}
const app = div(
div(
input(
{
type: 'search',
placeholder: 'Search products...',
value: () => searchQuery
},
on('input', e => {
searchQuery = e.target.value;
update();
}),
on('keydown', e => e.key === 'Enter' && fetchProducts())
),
button('Search', { onClick: fetchProducts })
),
when(() => state.status === 'loading',
div('Loading...')
).when(() => state.status === 'error',
div({ className: 'error' }, () => `Error: ${state.error}`)
).when(() => state.products.length > 0,
list(() => state.products, product =>
div(
{ className: 'product-card' },
h3(product.title),
p(() => `Category: ${product.category}`)
)
)
).else(
div('Click search to load products')
)
);
render(app, document.body);Core Concepts
The mental model:
- Create the UI with builder functions —
h1(...),when(),list(). - Use functions for values that depend on state —
h1(() => \Count: ${count}`)`. This registers a state-dependent value. - Mutate regular JavaScript state —
count++. - Call
update(). - Nuclo reevaluates the registered state-dependent values and applies the changes to the existing DOM.
State mutation and the update() call are plain JavaScript: you write count++ and call update() yourself. Nuclo does not watch your variables — nothing reevaluates until update() runs. The function from step 2 stays registered so it can be evaluated again, but only update() starts that evaluation.
1. Explicit Updates
nuclo doesn't auto-detect changes. You call update() when ready:
let name = 'World';
// Mutate freely
name = 'Alice';
name = name.toUpperCase();
// Update once when ready
update();Advantages of explicit update():
- Performance: Batch multiple mutations into a single update cycle
- Control: You decide exactly when the UI should refresh
- Predictability: Zero surprise re-renders, explicit update flow
- Simplicity: No proxies, no dependency graphs, just objects and functions
- Debugging: Set a breakpoint at
update()to trace all state changes
// Example: Batch updates for better performance
items.push(item1);
items.push(item2);
items.sort();
user.name = 'Alice';
update(); // One update for all changes
// vs. automatic tracking (hypothetical)
items.push(item1); // triggers update
items.push(item2); // triggers update
items.sort(); // triggers update
user.name = 'Alice'; // triggers update
// 4 updates instead of 1!2. Dynamic Functions
Zero-arg functions become state-dependent values: Nuclo keeps them registered and can evaluate them again, but only when you call update() — mutating the state they read does nothing on its own:
let count = 0;
div(
() => `Count: ${count}`, // Updates when update() is called
{ title: () => `Current: ${count}` } // Attributes too
)3. Conditional Rendering with when
First matching condition wins:
when(() => user.isAdmin,
div('Admin Panel')
).when(() => user.isLoggedIn,
div('User Dashboard')
).else(
div('Please log in')
)DOM is preserved if the active branch doesn't change.
4. List Synchronization
Lists use object identity (not keys) to track items:
list(() => items, (item, index) =>
div(() => `${index}: ${item.name}`)
)Mutate the array (push, splice, reverse), then call update(). Elements are reused if the item reference is the same.
API Reference
Core Functions
update()
Runs one synchronous update pass across every dynamic binding. Call this after mutating state:
count++;
items.push(newItem);
update();list(provider, renderer)
Synchronizes an array to DOM elements:
list(
() => items, // Provider function
(item, index) => div( // Renderer
() => `${index}: ${item.name}`
)
)Items are tracked by object identity. Mutate the array and call update() to sync.
when(condition, ...content)
Conditional rendering with chaining:
when(() => count > 10,
div('High')
).when(() => count > 0,
div('Low')
).else(
div('Zero')
)First matching condition wins. DOM is preserved if the active branch doesn't change.
Events
Attach strongly typed handlers directly as camel-cased on* attribute props such as onClick, onInput, and onChange. The event and currentTarget types are inferred from the attribute and element:
button('Remove', { className: 'remove', onClick: () => doDelete(row.id) })This is the preferred way to wire up events — it reads naturally inside object literals (e.g. row templates in a list()).
Use the on(event, handler, options?) helper instead when you need multiple listeners for the same event type on one element, or want to pass listener options such as { passive: true }:
button('Click me',
on('click', trackClick),
on('click', logClick, { passive: true })
)Standard names are inferred from the common HTML, media, video, and body event maps. Custom events can provide their event type explicitly with on<"app:ready", CustomEvent<Detail>>(...).
scope(...ids)
Registers an element as a named update root, so update("id") re-runs only the dynamic bindings contained within it instead of the whole page:
div(
scope('cart'),
span(() => `Items: ${cartItems.length}`)
)
cartItems.push(nextItem);
update('cart'); // only updates runtimes inside the "cart" scopeTag Builders
All HTML and SVG tags are available globally:
div(), span(), button(), input(), h1(), p(), ul(), li()
svgSvg(), circleSvg(), pathSvg(), rectSvg(), gSvg()
// ... 175 HTML and SVG builders totalAttributes
Pass attributes as objects:
div('Hello', {
className: 'container',
id: 'main',
'data-test': 'value',
style: { color: 'red', fontSize: '16px' }
})Dynamic attributes use functions:
div({
className: () => isActive ? 'active' : '',
disabled: () => !isValid,
style: () => ({ opacity: isVisible ? 1 : 0 })
})Styling
Nuclo includes a block-based CSS-in-TS engine. css() is available globally after import 'nuclo'. For theming and responsive breakpoints use createCss():
import 'nuclo';
const { css } = createCss({
colors: { primary: '#14b8a6' },
screens: { md: '(min-width: 768px)' },
});
const card = css({
p: 16,
rounded: 8,
md: { p: 24 },
hover: { borderColor: 'primary' },
});
const el = div(card, 'Hello');css() returns an object with a className key that can be passed directly to any tag builder. Global styles and keyframe animations use globalStyle() and keyframes().
Composing with cx()
cx() composes styles with exact, last-wins conflict resolution. When two generated classes target the same selector context, their declaration blocks are merged and the later value wins per property. It accepts results, raw class strings, falsy values, and nested arrays:
cx(base, isActive && activeStyle); // conditional
cx([base, isActive && activeStyle], extra); // arrays are flattenedTyped variants with variants()
variants() turns a base style plus named variant groups into a strongly-typed recipe. Variant names and values are inferred, so selecting an unknown one is a compile error, and true/false value groups are selected with real booleans. Each selected combination is merged, compiled once, and cached for subsequent calls.
import 'nuclo';
const { variants } = createCss({
colors: { primary: '#6366f1', danger: '#ef4444' },
});
const button = variants({
base: { rounded: 8, weight: 600, cursor: 'pointer' },
variants: {
intent: {
primary: { bg: 'primary', color: '#fff' },
danger: { bg: 'danger', color: '#fff' },
},
size: {
sm: { px: 10, py: 6, text: 14 },
lg: { px: 18, py: 12, text: 16 },
},
block: { true: { display: 'block', w: '100%' } },
},
defaultVariants: { intent: 'primary', size: 'sm' },
compoundVariants: [
{ intent: 'danger', size: 'lg', css: { weight: 700 } },
],
});
div(button({ intent: 'danger', size: 'lg' }), 'Delete'); // StyleResult — drop-in attributes
div(button(), 'Save'); // uses defaultVariants
button({ intent: 'ghost' }); // ✗ compile error: "ghost" is not a defined intentThe result is a StyleResult, so it drops straight into any tag builder or composes further with cx(button({ size: 'lg' }), extraClass).
Server-Side Rendering
Import nuclo/polyfill before nuclo in your Node.js server entry, then use renderToString() and getCssText() from nuclo/ssr:
import 'nuclo/polyfill';
import 'nuclo';
import { renderToString, getCssText } from 'nuclo/ssr';
import { App } from './app.ts';
const html = renderToString(App());
const styles = getCssText();
const page = `<!doctype html><html><head><style>${styles}</style></head>
<body><div id="app">${html}</div></body></html>`;On the client, call hydrate() instead of render() to attach Nuclo runtimes to the existing markup without re-creating DOM nodes.
nuclo/ssr also exports renderManyToString(inputs) for rendering a batch of trees at once, and renderToStringWithContainer(input, containerTag?, containerAttrs?) to wrap the output in a container element without a second serialization pass.
Best Practices
Batch Updates
Make multiple changes, then update once:
// Efficient: One update for all changes
items.push(item1);
items.push(item2);
items.sort();
update();
// Works but inefficient: Multiple updates
items.push(item1);
update();
items.push(item2);
update();Object Identity for Lists
Lists track items by reference. Mutate objects in place:
// Good: Mutate the object
todos[0].done = true;
update();
// Avoid: Creates new object, DOM element recreated
todos[0] = { ...todos[0], done: true };
update();Use .else() for Clarity
Even if not initially needed:
when(() => isLoading,
div('Loading...')
).else(
div('Ready') // Clear intent
)Advanced Patterns
Nested Structures
Combine when and list:
when(() => user.isLoggedIn,
div(
h1(() => `Welcome, ${user.name}`),
list(() => user.notifications, n =>
div(n.message, { className: () => n.read ? 'read' : 'unread' })
)
)
).else(
div('Please log in')
)Component-like Functions
function UserCard(user: User) {
return div(
{ className: 'user-card' },
img({ src: user.avatar }),
h3(user.name),
p(user.bio)
);
}
list(() => users, user => UserCard(user))Computed Values
function activeCount() {
return todos.filter(t => !t.done).length;
}
div(
() => `${activeCount()} remaining`
)Performance
- No virtual DOM diffing – Direct DOM manipulation for maximum efficiency
- Fine-grained updates – Only updates what changed, nothing more
- Element reuse – Lists intelligently reuse DOM elements when items move
- Branch preservation – Conditional branches persist until conditions change
For high-frequency updates (animations, game loops), batch mutations before calling update().
Debugging
Inspect Markers
Open DevTools to see comment markers that help you understand the structure:
<!-- when-start-1 -->
<div>Content</div>
<!-- when-end -->
<!-- list-start-2 -->
<div>Item 1</div>
<div>Item 2</div>
<!-- list-end -->These markers identify conditional and list boundaries in the DOM.
Common Issues
Content not updating?
- Ensure you're calling
update()after state changes - Verify your dynamic functions are returning the expected values — functions that return
null/undefinedrender as empty text and fill in on the nextupdate()
List items not reusing elements?
- Keep object references stable (mutate instead of replacing)
- Avoid creating new objects when updating properties
Gotchas
Parameter count decides what a function modifier means.
A modifier with zero declared parameters is treated as dynamic text (or a dynamic cn() className) and is called with no arguments. A modifier with one or more declared parameters is a node-modifier function receiving (element, index). Default and rest parameters don't count as declared parameters (fn.length is 0), so these are all treated as dynamic text, not node modifiers:
div((el = fallback) => el.id); // dynamic text — el is undefined!
div((...args) => args[0]); // dynamic text — args is empty!
div((el) => el.id = "x"); // node modifier — el is the element ✓If a modifier needs the element, declare it as a plain required parameter.
Detached nodes stop updating.
Registered state-dependent values (text, attributes, list(), when()) are pruned for any node that is disconnected from the DOM when update() runs. Re-attaching the node later does not restore them. Keep-alive and portal-style patterns should rebuild content after re-attaching instead of moving live subtrees between updates.
Documentation
Full documentation is available at https://nuclo.dev/
Author
Created by Danilo Celestino de Castro
License
MIT License - see LICENSE.md for details.
This library is free and open source. When using nuclo, please include attribution in your documentation or application.
TL;DR: Use it freely, give credit where it's due!
