Nuclo documentation
A practical reference for installing Nuclo, building with explicit updates, styling with typed CSS, and rendering server-side HTML.
npm create nuclo@latestOverview
#Nuclo is a small DOM library for TypeScript. You build the page with plain functions such as div() and button(), keep state in ordinary variables, and call update() when the page should change.
Nuclo has no virtual DOM, no compiler, no proxies and no signals.
import 'nuclo' let count = 0 const App = () => div( h1(() => `Count: ${count}`), button('Add one', { onClick: () => { count++; update() } }), ) render(App, document.getElementById('app')!)
The mental model
- Build the UI with tag builders.
- Pass a function for every value that depends on state.
- Change state with plain JavaScript.
- Call
update(). Nuclo runs the functions again and patches the DOM.
Changing a variable does not touch the DOM. The page changes only when you call update().
Quick Start
#create-nuclo scaffolds a Vite and TypeScript project that is ready for Nuclo:
It asks for a project name. To skip the prompt, pass the name and flags. With npm, put -- before the flags:
$ npm create nuclo@latest my-app -- --template basic --yes
Then install and start the dev server:
$ cd my-app $ npm install $ npm run dev
Options
[project-name]: the target folder. The default isnuclo-app.-t, --template <name>: the template to copy. Onlybasicexists today.-y, --yes: never prompt, use the defaults.-f, --force: write into a folder that is not empty.-h, --help: print the help.
Installation & Imports
#To add Nuclo to an existing project:
$ npm install nuclo
Or use pnpm add nuclo, yarn add nuclo or bun add nuclo. Nuclo has no dependencies.
Routing is not part of the core package. It ships separately as nuclo-router, which you install next to nuclo when you need it.
Globals
import 'nuclo' puts these on globalThis:
- 175 tag builders: 112 for HTML (
div,span,button, …) and 63 for SVG, with anSvgsuffix (svgSvg,pathSvg, …). See Tag Builders. - 16 helpers:
update,scope,when,list,region,into,on,render,hydrate,forceUpdate,css,cx,createCss,variants,keyframesandglobalStyle.
The import must run before any code that calls a builder. ES modules run in import order, so this fails:
import { App } from './app' // app.ts calls div() → ReferenceError: div is not defined import 'nuclo'
Put import 'nuclo' first in your entry file, or import it in every file that uses the globals.
Named imports
The helpers are also named exports. They are the same functions as the globals. getCssText() and resetStyles() are named exports only. Tag builders are globals only, so import { div } from 'nuclo' does not work.
import { render, update, getCssText } from 'nuclo'
Entry points
nuclo: registers the globals and exports the 16 helpers plusgetCssTextandresetStyles.nuclo/ssr:renderToString,renderManyToString,renderToStringWithContainerandgetCssText. See Server Rendering.nuclo/polyfill: a small DOM for Node and Bun. See nuclo/polyfill.nuclo/types: types only. See TypeScript.
TypeScript
#Nuclo ships its own types. You need two things:
"moduleResolution": "bundler".node16andnodenextare not supported yet.- The
DOMlib. TypeScript includes it by default when you do not setlib.
If any file in the project imports 'nuclo', every file sees the global types. You need nothing else. If no file imports it, for example in a separate tsconfig for tests, add the types yourself:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "types": ["nuclo/types"] } }
Or add /// <reference types="nuclo/types" /> to a .d.ts file.
Useful types
These types are global, so you use them without an import:
NodeModFn<Tag>: what a tag builder returns. Use it as a component's return type.NodeModLike<Tag>: anything a tag builder accepts as an argument.ExpandedElement<Tag>: whatrender()andhydrate()return.ElementTagName: any HTML tag name.ExpandedElementAttributes<Tag>: the attribute object a tag builder accepts, includingon*handlers,onMountandonDestroy. Use it for components that pass attributes through.MountCallbackandDestroyCallback:onMountandonDestroyhandlers.WhenBuilder,ListModifier: whatwhen()andlist()return.RegionOptions,RegionType: the optionsregion()takes.StyleResult,Style,ThemeConfig,CssInstanceand the other styling types. You can also import these:import type { StyleResult } from 'nuclo'.
function Badge(text: string): NodeModFn<'span'> { return span(text) }
ExpandedElement<Tag> marks every DOM member as optional. Cast it when you need the full element type: render(App, root) as HTMLDivElement.
Tag Builders
#Every HTML tag is a global function with the same name: div, span, input, … The one exception is <var>, which is var_ because var is a reserved word. Every SVG tag has an Svg suffix: svgSvg, pathSvg, circleSvg, …
Calling a tag builder does not create an element yet. div('Hi') returns a builder: a function that creates the element later. Nuclo calls it when you mount it with render() or place it inside another builder, when() or list(). Each use creates a new element.
let name = 'Ada' const Card = () => div( { id: 'card', className: 'card' }, h2('Profile'), p(() => `Hello, ${name}`), button('Rename', { onClick: () => { name = 'Grace'; update() } }), ) render(Card, document.body)
update().Open ↗Profile
Hello, Ada
Arguments
A builder takes any number of arguments and applies them from left to right:
- string, number, bigint: a text node.
() => value: text that changes onupdate(). See Dynamic Values.null,undefined: skipped.true,false: rendered as the text "true" or "false". Usewhen()for conditions.- A plain object: attributes. See Attributes.
- A
css(),cx()orvariants()result: adds its classes. on(...): an event listener or lifecycle hook.- Another builder,
when()orlist(): a child. - A DOM
Node: appended as it is. (el, index) => …: a custom modifier. See below.
Gotchas
div(isAdmin && span('Admin'))renders the text "false". Writediv(when(() => isAdmin, span('Admin'))).- Arrays are not children. Spread them:
ul(...names.map((n) => li(n))). For lists that change, uselist(). - A local variable with a tag name hides the builder. Inside
function Row(label: string),label(...)is not callable. Rename locals such aslabel,title,data,p,aandi. - Browsers expose every element that has an
idas a global variable. If the page contains<main id="main">before Nuclo loads, Nuclo leaves that global in place, somain(...)throws aTypeError. Use other ids.
Custom modifiers
A function that declares at least one parameter runs once, in argument order, while the element is being built. Its first argument is the element. The second is an internal index, so do not rely on it. If it returns a string or number, that becomes text. A Node is appended. An object is applied as attributes.
const field = input((el) => { el.autocomplete = 'off' })
Getting the element
render() returns the root element. Inside a tree, use on("mount", (el) => …) or a custom modifier. You can also call the builder function yourself to create a detached element:
const el = div('Hello')() as HTMLDivElement document.body.append(el)
Attach it before the next update(). A node that is not in the document when update() runs stops updating for good.
SVG
SVG builders take the same arguments. Nuclo sets every SVG attribute with setAttribute, so names such as "stroke-width" work as written.
const Icon = () => svgSvg( { viewBox: '0 0 24 24', width: 24, height: 24, className: 'icon' }, circleSvg({ cx: 12, cy: 12, r: 10, fill: 'currentColor' }), )
All builders
HTML: a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var_ video wbr
SVG: aSvg animateSvg animateMotionSvg animateTransformSvg circleSvg clipPathSvg defsSvg descSvg ellipseSvg feBlendSvg feColorMatrixSvg feComponentTransferSvg feCompositeSvg feConvolveMatrixSvg feDiffuseLightingSvg feDisplacementMapSvg feDistantLightSvg feDropShadowSvg feFloodSvg feFuncASvg feFuncBSvg feFuncGSvg feFuncRSvg feGaussianBlurSvg feImageSvg feMergeSvg feMergeNodeSvg feMorphologySvg feOffsetSvg fePointLightSvg feSpecularLightingSvg feSpotLightSvg feTileSvg feTurbulenceSvg filterSvg foreignObjectSvg gSvg imageSvg lineSvg linearGradientSvg markerSvg maskSvg metadataSvg mpathSvg pathSvg patternSvg polygonSvg polylineSvg radialGradientSvg rectSvg scriptSvg setSvg stopSvg styleSvg svgSvg switchSvg symbolSvg textSvg textPathSvg titleSvg tspanSvg useSvg viewSvg
Components
#A component is a plain function that returns a builder, such as button(...). There is no component API. Nothing is created until you render it or place it in a tree.
function Counter(label: string) { let count = 0 // one per call return button(() => `${label}: ${count}`, { onClick: () => { count++; update() }, }) } render(() => div(Counter('A'), Counter('B')))
State declared inside the function belongs to that instance. State declared outside is shared by every instance. forceUpdate() calls components again, which resets the state declared inside them.
Children are just arguments:
function Card(heading: string, ...children: NodeModLike<'div'>[]) { return div({ className: 'card' }, h2(heading), ...children) } render(() => Card('Hi', p('one'), p('two')))
Pass a plain object to set attributes. You can pass several objects. For the same key, the later value wins, except className and style, which merge. A value can be static, or a function with no parameters that runs again on every update().
let busy = false const save = button( { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 }, 'Save', )
update(); Save's disabled and aria-busy follow.Open ↗How a value is set
- If the HTML element has a property with that name (
value,checked,disabled,htmlFor,tabIndex, …), Nuclo sets the property. Otherwise it callssetAttribute(key, String(value)), for example for"aria-*"and"data-*". - On SVG elements, Nuclo always calls
setAttribute. nullandundefinedare skipped. A dynamic value that returnsnullorundefinedkeeps the old value. To clear a value, return"", orfalsefor a boolean property.- Booleans work for DOM boolean properties, such as
disabledon a button,checked,hiddenandrequired. Anywhere else,trueandfalseare written as the strings "true" and "false". - Use
"data-*"keys.datasetis not supported. innerHTMLis not escaped, and server rendering does not output it. Avoid it.
Classes
Use className. Static class strings, css() results and a dynamic className all merge. A dynamic className keeps the static classes, and when it returns "" only the static classes remain.
let active = false const base = css({ p: 8 }) const item = li(base, { className: 'item' }, { className: () => (active ? 'active' : '') }, 'Home') // class="<base> item", then "<base> item active" after active = true; update()
Do not use the class key on HTML elements. It sets the raw attribute and replaces every class applied before it, including css() classes.
Inline styles
style takes an object with camelCase keys, or a function that returns one.
let progress = 30 const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
- Nuclo adds no units. Write
"12px", not12. Unitless properties such asopacityandzIndexaccept numbers. "",nullandundefinedclear a property.- A dynamic style sets only the keys it returns. A key it stops returning keeps its old value, so return
""to clear it. - Style strings (
"color: red") and custom properties ("--gap") are not supported. Usecss()instead. It takes custom properties throughraw.
Events
#There are two ways to listen to events: on* attributes and the on() modifier.
on* attributes
In an attribute object, a function under a key such as onClick (on followed by a capital letter) is an event handler, not a dynamic value.
let query = '' const search = input({ placeholder: 'Search', value: () => query, onInput: (e) => { query = e.currentTarget.value; update() }, onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } }, })
- TypeScript infers the event type from the key and the element.
e.currentTargetandthisare the element. - Nuclo sets the native property, so
onClickbecomesel.onclick. There is one handler per event: a lateronClickreplaces an earlier one. onDoubleClickandonDblClickboth meandblclick. Lowercase keys such asonclickare type errors.- A name that is not a known event listens to its lowercase form:
onMyEventlistens to"myevent". - Errors thrown by the handler are not caught.
Type something, then press Escape.
on()
on() returns a modifier that calls addEventListener on the element.
const panel = div( on('scroll', onScroll, { passive: true }), on('click', logClick), on('click', closeMenu), )
- Every
on()adds a listener. All of them run, in order. optionsgoes straight toaddEventListener:capture,once,passive,signal.- Pass
on()straight to a builder and the element type is inferred:input(on('input', (e) => e.currentTarget.value)). - If a listener throws, Nuclo logs the error with
console.error, and the other listeners still run. - Nuclo removes the listener when it removes the element. On the server,
on()does nothing. - You can reuse one
on()value on many elements.
For a custom event, pass the event type as a generic:
type PickEvent = CustomEvent<{ id: number }> const picker = ul(on<'pick', PickEvent>('pick', (e) => choose(e.detail.id)))
Which to use
Use on* attributes for the usual case. Use on() when you need more than one listener for an event, listener options, or a custom event name such as "my-event".
onMount runs once, after the element is inserted. onDestroy runs once, when Nuclo removes the element. Write them as attributes or with on(). Both forms behave the same, and the element is typed from its tag.
let seconds = 0 const Timer = () => span( { onMount: () => { const id = setInterval(() => { seconds++; update() }, 1000) return () => clearInterval(id) // runs when the span is removed }, }, () => `${seconds}s`, ) const search = input(on('mount', (el) => el.focus()))
onMount starts an interval and returns its cleanup. Unmount runs the cleanup, so the count pauses until the timer mounts again.Open ↗When they run
onMountruns at the end of therender(),hydrate(),update()orforceUpdate()call that inserted the element. It never runs while the tree is being built.- If
onMountreturns a function, that function runs on destroy, after the element'sonDestroycallbacks. onDestroyruns when Nuclo removes the element: alist()row is removed, awhen()branch switches, aninto()leaves its region, orforceUpdate()drops it. It runs just before removal, so the element is still in the document.
Raw DOM removal
If you remove an element yourself, with node.remove() or innerHTML = '', its onDestroy and cleanups never run, and timers keep running. Nuclo has no unmount(). To tear down an app with cleanup, wrap it in when():
let mounted = true render(() => div(when(() => mounted, App())), root) mounted = false update() // App's onDestroy hooks and cleanups run
Order and errors
- You can register as many hooks as you like, in either form. They run in registration order.
onDestroyalways runs children first, then the parent.- Elements mount in the order of their first
onMounthook, which follows argument order, and each element runs all its hooks together. Pass a parent's hook before its children if they depend on it. - A hook that throws is logged with
console.error. The other hooks still run. - The callback must return nothing or a cleanup function, so do not make it
async. A returned Promise is never used as a cleanup. Start async work inside it:{ onMount: (el) => { void load(el) } }. - Hooks never run on the server. On the client, they run after
hydrate().
Nuclo never updates the page on its own. Change your state, then call update().
let count = 0 const Counter = () => button(() => `Clicked ${count} times`, { onClick: () => { count++; update() }, }) render(Counter)
update(), from the button or anything else on this page.Open ↗Clicked 0 times
Change as much state as you like, then call update() once:
let first = 'Ada' let last = 'Lovelace' render(() => p(() => `${first} ${last}`)) first = 'Grace' last = 'Hopper' update() // one pass, both changes
Call update() from event handlers, timers, fetch callbacks or onMount. Do not call it from inside a dynamic value.
A function with no parameters is a dynamic value. Nuclo runs it once when it builds the element, and again on every update(). Use dynamic values for text children, attribute values, style and className. For children that appear, disappear or repeat, use when() and list().
let name = 'Alice' let busy = false const Profile = () => div( h1('Welcome'), // static p(() => `Hello, ${name}`), // dynamic text button({ disabled: () => busy }, 'Save'), )
update(). forceUpdate() rebuilds the tree, so the static text catches up too.Open ↗static: Hello, Alice
dynamic: Hello, Alice
Nuclo checks the parameter count (fn.length). A function that declares a parameter is a custom modifier and runs only once. (x = 0) => … and (...args) => … have a length of 0, so they are dynamic.
Dynamic text
- A string, number, bigint or boolean renders as
String(value).() => ok && 'Saved'shows "false", so write() => (ok ? 'Saved' : ''). nullandundefinedrender as empty text.- A
css()orcx()result on the first run makes the function a dynamic class, not text. Seecx(). - A node, builder, array or plain object renders nothing, and never updates.
Dynamic attributes
- Nuclo writes a value only when it differs from the last value Nuclo wrote. It does not compare against the live DOM, so if a user types into an input and your function returns the same value as before, the text they typed stays.
- A result of
nullorundefinedwrites nothing, so the old value stays.classNameis the exception: any falsy result removes the dynamic classes and keeps the static ones. - A
stylefunction applies the returned object on every update.
Errors
If a dynamic value throws, Nuclo catches the error and the rest of the update still runs. Text becomes empty or keeps its old value, and an attribute keeps its old value. Both recover on the next update() that succeeds. The exception: a text function that throws on its first run stays empty for good.
update()
#Runs one synchronous pass over every dynamic value on the page. With ids, it only updates what is inside the elements marked with those scope() ids. Each pass runs these steps in order:
list(): reads the items again and adds, moves or removes rows.when(): checks the conditions again. A branch is rebuilt only when a different branch becomes active.- Dynamic attributes, including
styleandclassName. - Dynamic text.
onMountfor the elements that steps 1 and 2 inserted.
- It runs everything. Every dynamic value runs on every call, even when its data did not change. Only the DOM write is skipped. Keep dynamic values fast and free of side effects.
- Only nodes in the document. If a node is not in the document when
update()runs, Nuclo stops tracking it. It never updates again, even after you attach it. This applies tolist()andwhen()too. Render into a container that is in the document. - Static values never change. Plain strings and attribute values are fixed when the element is built. To rebuild them, use
forceUpdate(). - Errors. Text and attribute errors are caught, and the pass continues. If a
when()condition throws, the error is logged and thatwhen()stops updating. If alist()itemsorrenderItemfunction throws, the error escapesupdate()and the remaining steps do not run. - Server.
update()does nothing on the server.
update() runs every dynamic value, even when its data did not change. Other demos on this page push the count up too.Open ↗This dynamic value has run 1 times.
scope()
#Gives the element it is passed to one or more scope ids. update('id') then updates only what is inside that element, including the element's own attributes.
let cartItems: string[] = [] let user = 'Ada' const App = () => div( header(() => `Hi, ${user}`), aside(scope('cart'), span(() => `Items: ${cartItems.length}`)), ) render(App) cartItems.push('apple') user = 'Grace' update('cart') // "Items: 1", the header still says "Hi, Ada" update() // everything, the header now says "Hi, Grace"
- An element can have several ids:
scope('cart', 'sidebar'). update('a', 'b')updates everything insideaorb. If several elements use the same id, all of them update.- With nested scopes,
update('outer')includes the inner scope.update('inner')does not touch the outer one. - An id that no element in the document has updates nothing and raises no error, so check the spelling.
scope()does nothing on the server.
Renders content based on a condition. Nuclo checks the conditions again on every update(). The first true condition wins. If none is true, the .else() content renders. Without .else(), nothing renders.
let status: 'loading' | 'error' | 'ready' = 'loading' const Status = () => div( when(() => status === 'loading', p('Loading…')) .when(() => status === 'error', p('Something went wrong')) .else(p('Ready')), )
else() renders. A new branch is built from scratch.Open ↗Loading…
Conditions
- For state, pass a function. A plain boolean is read once:
when(open, …)never changes. - In TypeScript the function must return a boolean. Write
() => items.length > 0or() => user != null.
What updates
- While the same branch stays active, its elements are kept. Dynamic values, lists and
when()blocks inside it keep updating. - When a different branch becomes active, the old content is removed and its
onDestroyhooks run. The new branch is built from scratch and itsonMounthooks run. State in the old elements, such as input text or focus, is lost.
Branch content
Put children in a branch: text, dynamic text, builders, list() and nested when(). Pass builders such as p('Hi'), not DOM nodes: a DOM node stops updating after its branch hides once.
Attribute objects and on() in a branch apply to the parent element, and they stay after the branch hides. For a conditional attribute, use a dynamic attribute: { className: () => (active ? 'active' : '') }.
Chaining
.when() and .else() return a new builder and do not change the original. If you call .else() twice, the last call wins.
list()
#Renders one row per item. On every update(), Nuclo calls items() again and matches the new items to the existing rows by identity (===).
let fruits = ['Apple', 'Banana'] const Fruits = () => ul(list(() => fruits, (fruit) => li(fruit))) render(Fruits) fruits.push('Cherry') update()
How rows are matched
- An item that is still there keeps its row. Nuclo moves the row if needed, and does not call
renderItemagain. - A new item gets a new row. A removed item loses its row, and its
onDestroyhooks run. - Duplicate items each get their own row.
- You can change the array in place (
push,splice,sort) or return a new array.
A kept row is never rendered again, so use dynamic values for the fields that change:
const todos = [{ text: 'Write docs', done: false }] const Todos = () => ul( list(() => todos, (todo) => li({ className: () => (todo.done ? 'done' : '') }, () => todo.text), ), ) render(Todos) todos[0].done = true update() // same <li>, its class is now "done"
Copies are new items, so replacing the array with todos.map((t) => ({ ...t })) rebuilds every row.
What renderItem returns
- Return one element, such as
li(...),tr(...)or an SVG builder. Returnnullorundefinedto skip an item. Strings, text nodes and fragments render nothing. - Do not return
when()orlist()directly. Put them inside the row element:li(todo.text, when(() => todo.done, ' ✓')). - To hide items, filter in
items(), not inrenderItem. - Keep
renderItemfree of side effects. Nuclo may call it more than once for the same item.
The index
index is the item's position when its row was created. It is not updated when rows move. For the current position, use a dynamic value: li(() => `${names.indexOf(name) + 1}. ${name}`).
Iterables
Any iterable works: arrays, Set, generators and map.values(). Returning the same array or Set each time is fine. An iterator, such as a generator or map.values(), is used up after one pass, so create it inside items() on every call. Iterating a Map directly creates new [key, value] arrays each time, so every row is rebuilt. Use map.values() instead.
Placement
Rows appear where list() sits among its siblings, between two comment markers. Other children of the parent are not touched. list() also works inside SVG builders.
region() marks a named place in the tree. into() fills it from anywhere else, at any depth. The layout does not receive the content as a prop, and the component that builds the content does not know where it lands.
const Layout = () => div( aside(nav(a({ href: '/' }, 'Home'))), main(region({ id: 'main', empty: p('Nothing open.') })), ) // Anywhere in the tree, before or after the region const Article = () => into('main', article(h1('Hello'))) render(() => div(Layout(), Article()))
How it works
into()leaves only a comment where it is written. Its content renders between the region's markers.- Order does not matter. A
into()built before its region waits and shows the moment the region is built. Views show in the order they were written. - The content lives as long as the view. When the
when()that holds aninto()closes, or itslist()row is removed, the content leaves the region. Opening it again adds one copy, not two. - When a region goes away, for example its
when()closes, its views wait again. A region built later with the same id shows them. - A
into()whose id matches no region renders nothing and raises no error, so check the spelling. - Ids are unique in the tree. A second region built under a live id logs a warning.
- Regions work with server rendering. On
hydrate(), each view claims the HTML the server rendered instead of building it again.
Options
id: the nameinto()calls use to find the region.type:'latest'(the default) shows only the newest view.'stack'shows every view, in arrival order.empty: rendered while the region holds no views.
Stacked regions
With type: 'stack', a view that opens over another does not rebuild the one underneath. Its DOM, focus and form state stay. Use this for a layer such as a dialog over a page.
let open = false const App = () => div( region({ id: 'main', type: 'stack' }), into('main', form(input({ placeholder: 'Keeps its text' }))), when(() => open, into('main', div('On top'))), )
Several regions at once
Pass an object to fill more than one region from a single into(). A value can be one child or an array of children. Either form nests: an into() inside another view's content targets its own region.
const Page = () => into({ main: article(h1('Eager route')), sidebar: [h2('Related'), ul(li('One'), li('Two'))], })
A dialog opened from deep in the tree
The overlay region sits at the root. Any component, at any depth, opens a dialog by wrapping an into() in a when(). Closing the when() removes the dialog from the region.
let confirming = false const App = () => div( Page(), div({ className: 'overlay' }, region({ id: 'overlay' })), ) // Deep inside Page() const DeleteButton = () => div( button('Delete', { onClick: () => { confirming = true; update() } }), when(() => confirming, into('overlay', dialog({ open: true }, p('Delete this item?'), button('Cancel', { onClick: () => { confirming = false; update() } }), ), ), ), )
Toasts from list rows
Each row of a list() is a view. A stacked region shows them in order, and removing an item from the array removes its toast.
const toasts: { text: string }[] = [] const notify = (text: string) => { toasts.push({ text }) update() } const dismiss = (toast: { text: string }) => { toasts.splice(toasts.indexOf(toast), 1) update() } const App = () => div( button('Save', { onClick: () => notify('Saved.') }), div({ className: 'toasts' }, region({ id: 'toasts', type: 'stack' })), list(() => toasts, (toast) => into('toasts', div(toast.text, button('×', { onClick: () => dismiss(toast) }))), ), )
A page title set by the page
With the default type: 'latest', the newest view wins. The layout owns the heading, and whichever page is open names it. A page without an into('title', …) leaves the empty text.
let page: 'home' | 'settings' = 'home' const Layout = () => div( h1(region({ id: 'title', empty: 'My app' })), when(() => page === 'home', Home()).else(Settings()), ) const Home = () => p('Welcome.') const Settings = () => div( into('title', 'Settings'), form(/* … */), )
Dynamic values inside a view
The content of an into() is ordinary Nuclo content. Functions in it are state-dependent values and refresh on update(), wherever the region is.
const selected = new Set<string>() const Layout = () => div( header(region({ id: 'status' })), Table(), ) const Table = () => div( into('status', () => `${selected.size} selected`), list(() => rows, (row) => label( input({ type: 'checkbox', onChange: (e) => { e.currentTarget.checked ? selected.add(row.id) : selected.delete(row.id) update() } }), row.name, ), ), )
A region that comes and goes
The dashboard's into() is written once. While the user is signed out there is no main region, so the view waits. Signing in builds the region and the view shows. Signing out removes the region and the view waits again.
let user: { name: string } | null = null const App = () => div( when(() => user !== null, main(region({ id: 'main' })), ).else( LoginForm(), ), into('main', Dashboard()), )
A view with its own region
A view can contain a region(), and other views can target it. Here the editor page brings a tabs region with it, and each tab fills it. When the editor closes, its region goes with it.
let tab: 'code' | 'preview' = 'code' const Editor = () => into('main', div( nav( button('Code', { onClick: () => { tab = 'code'; update() } }), button('Preview', { onClick: () => { tab = 'preview'; update() } }), ), section(region({ id: 'tabs' })), ), when(() => tab === 'code', into('tabs', textarea())) .else(into('tabs', iframe({ src: '/preview' }))), )
Live example
import 'nuclo' let page: 'home' | 'article' | null = 'home' let dialog = false const go = (next: typeof page) => () => { page = next dialog = false update() } const Layout = () => div( nav( button('Home', { onClick: go('home') }), button('Article', { onClick: go('article') }), button('Close page', { onClick: go(null) }), ), main( region({ id: 'main', type: 'stack', empty: p('Nothing open.') }), ), aside(region({ id: 'side', empty: p('No related links.') })), ) // Pages fill the layout's regions from outside it. const Home = () => into('main', input({ placeholder: 'Type here, then open the dialog' }), button('Open dialog', { onClick: () => { dialog = true; update() }, }), ) const Article = () => into({ main: article(h2('Article'), p('One into() fills both regions.')), side: [h2('Related'), ul(li('One'), li('Two'))], }) // Stacks over Home, so Home keeps its DOM and the text you typed. const Dialog = () => into('main', div( p('On top of Home.'), button('Close', { onClick: () => { dialog = false; update() } }), ), ) export const RegionApp = () => div( Layout(), when(() => page === 'home', Home()), when(() => page === 'article', Article()), when(() => dialog, Dialog()), )
into().Open ↗No related links.
update() only runs dynamic values. Static values, such as the text in h1(labels[lang].title), are fixed when the tree is built. forceUpdate() calls your components again and patches the live DOM to match, static values included. Use it for rare, app-wide changes, such as switching the language. Use update() for everything else.
const labels = { en: { title: 'Hello' }, pt: { title: 'Olá' } } let lang: keyof typeof labels = 'en' const App = () => div(h1(labels[lang].title)) render(App, root) lang = 'pt' forceUpdate() // same <h1> element, now "Olá"
update() refreshes only the dynamic lang line. forceUpdate() calls App again and patches the same <h1>. The explicit form rebuilds only this tree, not the whole page.Open ↗lang = 'en'
Hello
Static text, fixed on build.
With no arguments, forceUpdate() rebuilds every root rendered with render(App) or hydrate(App). Roots rendered with render(App()) are skipped. If a component throws, the error is logged and that root keeps its old DOM. An error thrown later, while the new build is applied, is logged too, but the parts already patched stay patched.
What stays and what changes
- Children are matched in order, by tag. A matching element is reused, so focus and typed input values stay, and its
onMounthooks do not run again. Once one child's tag differs, that child and every later sibling are built fresh. - Text, attributes, classes, styles, listeners,
when()branches andlist()rows follow the new build. - New elements are created, and their
onMounthooks run. Elements the new build no longer makes are removed, and theironDestroyhooks run. list()rows are matched by position too, not by item, so a reused row element can end up showing a different item.- Attributes the new build no longer sets are not removed.
Explicit form. forceUpdate(App(), parent) rebuilds one tree in parent and returns its root. It does not register the root for later calls. Like hydrate(), it expects the root to be the first node in parent. If the first node has a different tag, it is replaced.
On the server, forceUpdate() does nothing.
render()
#Builds your tree and appends its root element to parent. parent defaults to document.body. It returns the root element.
import 'nuclo' import { App } from './app.ts' render(App, document.getElementById('app')!)
- You can pass the component (
render(App)) or the builder it returns (render(App())). The DOM is the same. Onlyrender(App)letsforceUpdate()rebuild the root later, so prefer it. - It appends. It does not clear
parent, so calling it twice adds two copies. - Every
onMountin the tree has run whenrender()returns. - Render into an element that is in the document. See
update(). - The root must be an element, such as
div(...), notwhen()orlist(). indexis internal. It does not choose where the element is inserted. Leave it out.
A component passed as render(App) must take no parameters. A required parameter is a type error. An optional one type-checks, but render() throws at runtime. Wrap a component that takes props:
const Page = (props: { name: string }) => div(h1('Hi ', props.name)) render(() => Page({ name: 'Ana' }), root)
hydrate()
#Takes over HTML made by renderToString(). It reuses the existing elements, attaches event listeners, and connects dynamic values, when() and list(), so update() works afterward. Then it runs the onMount hooks. parent defaults to document.body. It returns the root element.
import 'nuclo' import { App } from './app.ts' hydrate(App, document.getElementById('root')!)
- Use the same component, and the same data, as the server.
- Hydrate into a dedicated container. The app's root must be its first node, although whitespace is fine. If another node comes first, Nuclo replaces that node and leaves the server copy behind.
- As with
render(), passApp, notApp(), soforceUpdate()can rebuild the root later.Appmust take no parameters. With an optional one,hydrate()silently attaches nothing. Wrap a component that takes props:hydrate(() => Page(props), root). - If there is no server HTML, use
render().
When the server and the client differ
The client wins:
- A different root tag: that node is replaced with fresh DOM. An empty container: fresh DOM is appended.
- Different text is patched. Different
list()items andwhen()branches follow the client. - Extra server children are removed, and missing ones are created.
- Attributes that the server wrote but the client does not set are left as they are.
renderToString() writes HTML comments as markers, such as <!-- text-0 --> before each text node. hydrate() needs them, so do not strip comments from the server HTML. Without the markers, hydration deletes the list() rows, and list() and when() stop updating.
Server Rendering
#Render a component to an HTML string on the server. In the browser, call hydrate() with the same component to make that HTML interactive. Server runtimes have no document, so import nuclo/polyfill first.
let count = 0 const heading = css({ color: 'red' }) export const App = () => main( h1(heading, () => `Count: ${count}`), button({ onClick: () => { count++; update() } }, '+1'), )
import 'nuclo/polyfill' import 'nuclo' import { renderToString, getCssText } from 'nuclo/ssr' import { App } from './app.ts' const html = renderToString(App) const page = `<!doctype html> <html> <head><style id="nuclo-styles">${getCssText()}</style></head> <body> <div id="root">${html}</div> <script type="module" src="/client.js"></script> </body> </html>`
import 'nuclo' import { App } from './app.ts' hydrate(App, document.getElementById('root')!)
Keep id="nuclo-styles" on the style tag. The client reuses that element and adds only new rules. See getCssText().
What runs on the server
- Each
renderToString()call builds the tree and runs each dynamic value once. - Event handlers,
onMountandonDestroynever run.update(),scope()andforceUpdate()do nothing. - Rendering is synchronous. Load your data first, then render.
- Nothing stays registered after a render, so one process can serve many requests. Pass request data as arguments, such as
renderToString(Page(user)). Module-level variables are shared by all requests. - Do not call
render()on the server. It appends to the shareddocument.body.
renderToString()
Takes a builder (App()), a component (App) or a DOM node. null and undefined return "". The root must be an element. A component passed as renderToString(Page) must take no parameters, so pass Page(user) for one that takes data.
renderToString(p('Hello')) // '<p><!-- text-0 -->Hello</p>'
- Text and attribute values are HTML-escaped. Text inside
script()andstyle()is written as is, with only closing tags broken up, so never put user input there. - It does not throw. If the component, a
list()function or awhen()condition throws, or the polyfill is missing, it logs the error and returns"". Check for an empty result if the request should fail. A text or attribute function that throws only renders empty.
renderManyToString()
The same as inputs.map(renderToString).
renderToStringWithContainer()
Wraps the output in one element. containerTag defaults to "div", and containerAttrs to {}. Attribute values are escaped. Attribute names are written as given, so use HTML names such as class. Never pass user input as the tag.
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' }) // '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'
nuclo/polyfill
#A small DOM so Nuclo can build trees in Node and Bun. Import it once, before your first render. Most apps only need the plain import.
- When there is no
window, it setsglobalThis.document,Node,ElementandHTMLElement, but only the ones that are missing. It does not createwindow. - In a browser, or under jsdom, it does nothing.
EventandCustomEventare the runtime's own constructors.Node,ElementandHTMLElementare aliases of the polyfill classes.
It is a DOM for building trees, not a browser. It has no innerHTML and no getElementById, querySelector() returns null, and event listeners are ignored. Use renderToString() to get the HTML.
Turns a typed style object into generated CSS classes. Pass the result to any tag builder. In the browser, the rules go into one <style id="nuclo-styles"> element. On the server, getCssText() returns them.
const card = css({ p: 16, rounded: 8, bg: '#fff7ed', hover: { bg: '#ffedd5' }, }) const Card = () => div(card, 'Simple card')
css()makes one class for the base declarations, plus one class for each pseudo key, selector and media block. The card above gets two classes.- Class names are hashes of the content. Identical styles share a class, and the server and the client produce the same names.
- Define styles once, at module level.
css()caches by object, so a new object on every call is processed again each time. - Changing a style object after passing it to
css()does nothing. - Every style stays registered for the life of the page or server process. For values that change per item, such as a width from data, use the
styleattribute.
Named styles
Pass a name first to get a readable class name:
const pageTitle = css('page-title', { text: 24, hover: { color: 'red' } }) pageTitle.className // "page-title page-title-<hash>"
- The base class is exactly the name. Other blocks get
name-<hash>. - The name must be a valid class name. Otherwise Nuclo warns and generates one.
- Names are global. If two different styles use the same name, Nuclo warns.
Using the result
const card = css({ p: 8 }) div(card) // best div(card, { className: 'featured' }) // className merges div(card, { class: 'featured' }) // replaces: only "featured" is left
String(card) and card.className both return the class names.
Style Objects
#The object you pass to css() uses common CSS properties in camelCase, a set of shorthands, and nested keys for pseudo states, selectors and media queries. TypeScript autocompletes all of them. Properties outside the typed set, such as borderTopLeftRadius, fill or paddingInline, go in raw (see below).
Values
- Numbers become
px, except0and unitless properties:zIndex,opacity,fontWeight,lineHeight,flex,flexGrow,flexShrink,order,aspectRatio,gridColumnandgridRow. Inraw,zoom,scale,column-count,orphans,widows,tab-sizeandanimation-iteration-countare unitless too. - Strings are used as written.
null,undefinedandfalseare skipped. lineHeight(andleading) is unitless, soleading: 24means 24 times the font size. Write"24px"for pixels.
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' }) // padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%
Shorthands
| Key | CSS |
|---|---|
p, pt, pr, pb, pl | padding, and each side |
px / py | padding left and right / top and bottom |
m, mt, mr, mb, ml, mx, my | the same for margin |
w, h, minW, maxW, minH, maxH | width, height, and their min and max |
size | width and height |
bg | background |
text / font / weight | font-size / font-family / font-weight |
leading / tracking | line-height / letter-spacing |
align / items / justify | text-align / align-items / justify-content |
z / rounded / shadow / select | z-index / border-radius / box-shadow / user-select |
These keys take true:
| Key | CSS |
|---|---|
row | display: flex; flex-direction: row |
col | display: flex; flex-direction: column |
center | align-items: center; justify-content: center (does not set display) |
truncate | overflow: hidden; text-overflow: ellipsis; white-space: nowrap |
Pseudo states
hover, focus, focusVisible, focusWithin, active, visited, disabled, enabled, checked, required, invalid, valid, readOnly, first (:first-child), last (:last-child), only (:only-child), odd, even, empty, placeholderShown, placeholder, before, after, selection, marker, firstLine and firstLetter. They nest: hover: { focus: {…} } gives :hover:focus.
Selectors and at-rules
A key that starts with & is a selector, and each & becomes the class. A key that starts with @ is an at-rule: @media, @container or @supports.
const menu = css({ p: 8, '& > li': { py: 4 }, '& > li:hover': { color: 'red' }, '&:is(.dark *)': { color: 'white' }, '@media (min-width: 768px)': { p: 16 }, })
- The key must start with
&. A key such as".dark &"is ignored, so write"&:is(.dark *)". - Two nested
@mediablocks combine withand. A query led by a media type (print,only screen) is placed first, because the grammar allows a type only at the start. A pair that cannot be flattened — two media types, or a leadingnot— warns and uses the inner query alone. Any other nested at-rule replaces the outer one.
raw
Use raw for any property, with the name written exactly as in CSS. Numbers still get px, so pass strings:
css({ raw: { '--gap': '8px', '-webkit-line-clamp': '2' } })
Any other flat key is converted to kebab-case and written out as is, so a typo such as colr: 'red' becomes a declaration the browser drops. A nested object under a key that is not a pseudo state, a screen, or an & or @ key is ignored. TypeScript reports both.
Combines styles. When two inputs set the same property, the later input wins.
const base = css({ p: 8, color: 'red' }) const blue = css({ color: 'blue' }) cx(base, blue) // padding: 8px; color: blue cx(base, false, null, 'extra') // base's classes plus "extra" div(cx(base, blue), 'Blue text')
- Falsy inputs are skipped, arrays are flattened, and strings are split and de-duplicated.
- Generated classes for the same block (base, the same pseudo, the same media query) merge into one new class. Other class names pass through unchanged.
- Only
cx()knows argument order. Withdiv(base, blue), both classes are added, and the rule that was created last wins. Put styles that override each other in onecx()call. - When a named style's block merges with another input's block, the merged class is
name-<hash>, not the bare name. Blocks that do not merge keep their class, socx(pageTitle, 'extra')still haspage-title.
Dynamic classes
Pass a function that always returns cx(...):
const baseButton = css({ px: 12, py: 8, border: '1px solid #ccc' }) const activeButton = css({ bg: '#ff3f00', color: 'white' }) let active = false const Toggle = () => button( () => cx(baseButton, active && activeButton), () => (active ? 'Active' : 'Inactive'), { onClick: () => { active = !active; update() } }, )
The function must return a style result on its first run. () => active && activeButton renders the text "false" instead of a class.
Returns its own css, cx, variants, keyframes and globalStyle, bound to a theme. A theme gives names to values: with the theme below, borderColor: 'border' writes #e5e7eb, and TypeScript autocompletes the names. Each entry in screens becomes a style key for a media query, such as md: {…}.
export const { css, cx, variants } = createCss({ colors: { primary: '#ff3f00', border: '#e5e7eb' }, radii: { card: '12px' }, screens: { md: '(min-width: 768px)' }, }) const panel = css({ p: 12, rounded: 'card', border: '1px solid', borderColor: 'border', md: { p: 24 }, })
| Theme key | Used by |
|---|---|
colors | bg, backgroundColor, color, borderColor and each side, outlineColor, caretColor, textDecorationColor |
fonts | font, fontFamily |
shadows | shadow, boxShadow |
radii | rounded, borderRadius |
screens | new style keys, such as md: {…} |
- A theme name resolves only when it is the whole value.
border: "1px solid primary"is not resolved. - A screen value without
@becomes@media <value>. A value that starts with@is used as written, for example"@container (min-width: 400px)". - Screens exist only on instances made with
createCss(). The globalcss()has none, but it accepts@mediakeys. - All instances and the global helpers share one stylesheet. Screen media rules are output in the order the screens are declared, not the order you use them, so list
min-widthscreens from small to large. Use onecreateCss()instance per app, so that order is the one in your theme. themeon the result is the theme you passed.
Builds a function that returns a style for a set of options, such as a button's intent and size.
const buttonClass = variants({ base: { px: 12, py: 8, rounded: 6 }, variants: { intent: { primary: { bg: '#2563eb' }, danger: { bg: '#dc2626' } }, size: { sm: { text: 12 }, lg: { text: 18 } }, block: { true: { w: '100%' } }, }, defaultVariants: { intent: 'primary', size: 'sm' }, compoundVariants: [{ intent: 'danger', size: 'lg', css: { weight: 700 } }], }) button(buttonClass(), 'Save') // primary, sm button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
- Styles merge in this order:
base, the chosen value of each group, then each matching compound. Later styles win per property. - A missing,
undefinedornulloption uses the default. A group with no default is skipped. - A group with only
trueandfalsekeys takes a boolean. - A compound applies when every group it lists matches, defaults included.
- Each selection is built once and cached.
keyframes() registers an animation and returns its generated name. Frame keys are from, to and percentages, such as "50%" or "0%, 100%". Frames take flat properties only. Identical frames return the same name.
const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 }, }) const panel = css({ animation: `${fadeIn} 200ms ease-out` })
globalStyle() adds a rule for any selector:
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' }) globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
- The selector is used exactly as written.
- Only flat properties work. Pseudo keys, screens and
@keys are ignored, soglobalStyle()cannot write media rules. - The same rule is added once. A different style for the same selector adds a second rule, and the later one wins.
getCssText() & resetStyles()
#getCssText() returns every rule that css(), cx(), variants(), keyframes() and globalStyle() have created in this process: base rules first, then the @media, @container and @supports blocks. Use it for server rendering.
- Call it after
renderToString(). Rules created during the render are included. - It covers the whole process, not one page or request, and it only grows. That is safe, because class names are content hashes.
- Put it in
<style id="nuclo-styles">. The client reuses that element and skips the rules already in it. With another id, or none, the client adds a second stylesheet with every rule again. - The text is not escaped, so never put untrusted input into style values.
resetStyles() is a test helper. It removes every rule and the #nuclo-styles element. Styles you created earlier keep their class names but lose their CSS. Do not call it in an app or on a server.
import { resetStyles } from 'nuclo' beforeEach(() => resetStyles())
nuclo-router is not part of the Nuclo core package. Install it next to nuclo. It is a preview release, so its API may still change.Routing for Nuclo: server rendering, hydration, code-split routes and idle preloading, built on list() and update(). About 4.9 KB gzipped, with no dependencies beyond Nuclo itself and no side effects, so it tree-shakes away when unused.
$ npm install nuclo nuclo-router
Or use pnpm add, yarn add or bun add with the same two names. It needs nuclo 0.3 or later as a peer dependency.
The router decides what to load. The app says where with router.outlet(): one list() over the layer stack, one row per layer, so an ordinary page is a single row. The server and the client build the same tree, so hydrate() claims the server's nodes instead of replacing them. Everything else is history, one delegated click listener and requestIdleCallback.
One import
export const router = createRouter(routes) // define once, import anywhere await router.start() // resolve the current URL render(App, document.getElementById('app')!) // App places router.outlet() and reads router.path, router.go(), …
start() resolves the matching route's modules before it returns, so the first tree is complete on both the server and the client. After that the router reads through to the route it started: router.path, router.pending, router.go().
A whole app
// isomorphic: imported by the browser and the server import { createRouter } from 'nuclo-router' export const router = createRouter({ '/': () => import('./pages/Home.ts'), '/docs': () => import('./pages/Docs.ts'), '/blog/:slug': () => import('./pages/Post.ts'), '*': () => import('./pages/NotFound.ts'), }) export const App = () => div( header(a({ href: router.href('/') }, 'Home'), a({ href: router.href('/docs') }, 'Docs')), main( when(() => router.pending, Spinner()), when(() => router.error !== null, ErrorView()), router.outlet(), // where the pages land ), footer('© nuclo'), )
import type { RouteContext } from 'nuclo-router' import { router } from '../routes.ts' // A page is plain content export default function Post(ctx: RouteContext) { // A query-only change keeps the page, so the query is read live. return article(h1(ctx.params.slug), p(() => (router.search.has('preview') ? 'draft' : ''))) }
import 'nuclo' import { router, App } from './routes.ts' await router.start() hydrate(App, document.getElementById('app')!) // or render() without SSR
import 'nuclo/polyfill' import 'nuclo' import { renderToString } from 'nuclo/ssr' import { router, App } from './routes.ts' // one Route per request const route = await router.start(request.url) const html = route.run(() => renderToString(App))
That is the whole integration. Links work because an <a href> is just an <a href>. One delegated listener on document intercepts the links the router owns and leaves every other link to the browser.
On the server, start() returns a Route per request and leaves the router alone, so concurrent requests cannot see each other's route. route.run() makes that Route the one the router, and so the outlet, reads for the length of the render. In the browser there is one Route per page load, and start() makes it the router's own. No run() is needed.
A page receives its RouteContext and its PageProps, as (ctx, { layer, data, outlet, beforeLeave }), and may take fewer arguments. It returns anything list() accepts. To fill another part of the layout as well, return an into() from inside the page, such as div(article(…), into('sidebar', nav(…))). That content leaves with the page. See Pages & Regions.
| Pattern | Matches | params |
|---|---|---|
/, /docs/intro | exactly that path | {} |
/blog/:slug | /blog/hello | { slug: 'hello' } |
/u/:id/edit | /u/7/edit | { id: '7' } |
/files/*rest | /files/a/b.txt | { rest: 'a/b.txt' } |
* | anything unmatched, your 404 | { '*': '…' } |
Precedence is static, then dynamic in declaration order, then catch-all with the longest prefix first. So '*' only matches what nothing else does and can sit anywhere in the table. Trailing slashes, duplicate slashes and percent-encoding are normalized, and param values arrive decoded. Matching is a segment comparison per pattern. No regexes are built or run.
Relative routes
A pattern written with ./ is relative to the parent that declares it, and an href written with ./ is relative to the active route. Together they make a reusable section that means the same thing under every parent. A table's value is either a loader or another table, whose keys are relative to it:
import type { RouteTable } from 'nuclo-router' // a section, with no idea where it lives export const recordSection: RouteTable = { '/': () => import('./Record.ts'), // the parent path itself './preview': () => import('./Preview.ts'), './preview/raw': () => import('./Raw.ts'), }
// declare it wherever it belongs createRouter({ '/': () => import('./pages/Home.ts'), '/invoices/:id': recordSection, '/customers/:id': recordSection, '*': () => import('./pages/NotFound.ts'), })
Those two lines produce all six patterns with no path written twice. Both parents get the same loader objects, so they share one module cache and one import().
- Inside a child table,
'./x','/x'and'x'all mean the same thing,'/'means the parent path itself, and'*rest'gives the section its own catch-all. Nesting goes as deep as you like. - Top-level keys are used as written, so
'*'stays'*'. - A child under a
*catch-allparent is acreateRouter()error, since a catch-all has to end its pattern.
Parent pages and outlet()
A parent and its children are a real hierarchy. The parent renders, and its child renders inside it, wherever it calls outlet():
import type { PageProps, RouteContext } from 'nuclo-router' // a parent page takes its outlet from (ctx, { outlet }) export default function Record(ctx: RouteContext, { outlet }: PageProps) { return div(Header(ctx), input({ id: 'notes' }), main(outlet())) }
Navigating between children does not rebuild the parent. Its DOM node is the same node, so its focus, its scroll position, its half-typed input and any state in its closure all survive.
- A page with no child route renders nothing from its outlet, so a layout can call it unconditionally. A parent that has a child and never calls
outlet()swallows it. - There are no index routes.
'/'is the layout itself, so the parent path renders the layout with an empty outlet rather than a separate index page inside it. Put the landing content in the layout, or give it a real child path.
The same call then works from either parent, and from a page that does not know which one it is in:
router.href('./preview') // '/invoices/42/preview' or '/customers/7/preview' await router.go('./preview') await layer.push('./preview') // the usual shape: a child layer
./x, ../x, . and .. resolve against the current route's path, not the URL's "directory". From /invoices/42, ./preview is /invoices/42/preview, where a browser would have said /invoices/preview, and a trailing slash on the URL changes nothing. ../ walks up a segment and stops at the root. The query and hash are carried through, base is re-added, and the resolved absolute path is what goes into history. This is the same deviation from URL resolution that React Router makes for <Link to="./x">.
- Only
./and../are route-relative. A barepreviewin an href is left to ordinary browser rules. - Build relative links with
router.href('./preview')rather than writinghref="./preview"by hand.href()resolves at render time, so the markup that ships is absolute and still correct for a crawler, for a middle-click and with JavaScript disabled.
What is kept, and what is rebuilt
| Node | On navigation |
|---|---|
| a parent whose pattern and path are unchanged | kept: same node, and its load() is not re-run |
| the matched page | kept when its pattern and path are unchanged and it has no load(), so a query or hash change keeps it. A route with a loader refreshes on every visit. |
| a parent whose path changed | rebuilt, along with everything under it |
| a pushed layer | renders the matched page alone. Its parents are already mounted in the row beneath it. |
A kept page keeps the ctx it was built with. That is the meaning of ctx everywhere: read router.* for live values. Query-driven state, such as filters, pagination and tabs, reads router.search inside a dynamic value and writes with router.go('?page=2', { replace: true }). The page, its focus and its half-typed input stay put.
Pages & Regions
#router.outlet() puts the page in one place. A page that also needs a sidebar, a toolbar or a heading somewhere else in the layout fills it with into(). The layout marks the place with region(), and the page returns an into() next to its own content. The layout never receives page content as a prop, and navigation never rebuilds it.
export const App = () => div( aside( input({ placeholder: 'Filter' }), // keeps its text across navigations region({ id: 'sidebar', empty: p('Nothing to show.') }), ), main(router.outlet()), )
import type { PageProps, RouteContext } from 'nuclo-router' export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) { return div( article(h1(post.title), p(post.body)), into('sidebar', nav(h2('In this post'), ul(list(() => post.headings, (h) => li(h))))), ) }
The page's content is a row of the outlet's list(), and an into() lives as long as the row that holds it. So the sidebar content arrives with the page and leaves with it: navigating to a page without an into('sidebar', …) shows the region's empty content again. The page can use its ctx and data for both parts, since both are built by the same function.
What happens on navigation
- A kept page keeps its regions. A query or hash change that keeps the page does not touch the region content either. Read
router.searchinside a dynamic value if the sidebar depends on it. - A rebuilt page refills them. When the route changes, the old row is removed and the new page's
into()views take over. Nothing is left behind. - A pushed layer adds a view. The layer is a later row of the outlet, so its
into()is a newer view of the same region. With the defaulttype: 'latest'it covers the page's view until the layer closes, and then the page's view shows again. Withtype: 'stack'both show. - A parent page can fill regions too. Its views stay while its children change, because the parent is not rebuilt.
- Server rendering works as is. The server builds the page inside
route.run(), so itsinto()views are in the HTML, andhydrate()claims them.
Several regions from one page
Pass an object to fill more than one region at once. The page's main content still goes through the outlet, so return it beside the into():
export default function Report(ctx: RouteContext) { return div( table(…), into({ toolbar: [button('Export'), button('Print')], sidebar: nav(a({ href: router.href('./summary') }, 'Summary')), }), ) }
Which tool for which job
outlet()is for a child route: content the URL decides, rendered inside its parent page. See Nested Routes.into()is for the matched page spreading itself across the layout. The content belongs to the page and uses its data.matches()is for the layout deciding from the URL alone, with its own small table and no page involved. Seematches().
All of these belong to the one matched route. The router does not drive two independent areas from one URL, so a sidebar with its own routes is a region a page fills, not a second outlet. A sidebar that really wants its own router, loading and fallback keeps one unstarted and resolves with match() — see Multiple Routers & Combined Loading.
A page module may export a load function. It runs before the page is built, on every navigation to that route, and whatever it returns is handed to the page as data:
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router' export const load: DataLoader<Post> = (ctx) => fetchPost(ctx.params.slug) export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) { return article(h1(post.title), p(post.body)) }
The route table is unchanged. The loader travels with the page. It gets the matched RouteContext, so params, search and path are all available. It may be sync or async, and it runs on the server too, so a server-rendered page ships with its data already in the HTML instead of fetching after hydration.
The module is cached. The data is not. A route's chunk is fetched once and reused, and its loader runs again on every navigation. That is why /blog/a and /blog/b cannot be confused, and why a failed loader can simply be retried.
| Situation | Behavior |
|---|---|
| while it runs | router.pending is true and the page you were on stays up |
| it throws or rejects | lands on router.error. go() still never rejects, and the navigation can be retried. |
during start() | the rejection comes out of start(). There is no Route yet to put it on. |
| a pushed layer | runs its own loader, with its own data |
| the idle preloader | warms modules only. It has no context to pass, and would be fetching data for a route you may never open. |
| re-navigating to the same URL | the loader runs again and the row rebuilds. A route without a loader keeps its row. |
To revalidate, navigate to where you already are without adding a history entry:
await router.go(router.url, { replace: true }) // runs load() again
Starting the data with the chunk
A load exported by the page module can only run once that module has arrived, so a cold navigation fetches the chunk, then the data. To start both at once, declare the loader in the table with withLoad():
import { createRouter, withLoad } from 'nuclo-router' import { loadPost } from './data.ts' // small, in the main bundle createRouter({ '/blog/:slug': withLoad(() => import('./pages/Post.ts'), loadPost), })
It replaces the module's own load and runs on every visit like it. A rejection while the chunk is still loading lands on router.error like any other.
There is no serialization of server data into the HTML, by design. The client's load runs again on hydration. If that matters, have the server emit route.data, for example as window.__DATA__, and the loader take it first. route.data is the matched page's data only. A parent's load() result is not on it.
Code splitting and preloading
A route's value is always a function that returns the page component, so the component is reached lazily:
'/a': () => import('./A.ts'), // default export (+ optional load) '/b': () => import('./B.ts').then(m => m.BPage), // named export '/c': () => CPage, // eager, not split '/d': () => ({ default: DPage, load: loadD }), // eager, module-shaped
Only the module-shaped forms can carry a load export. Picking a named component with .then(m => m.BPage) hands the router the function alone.
Each module is imported once and cached. Concurrent navigations to the same route share one import(). Once the user has left the page alone for idleTimeout (a second by default; no taps, keys, wheel or scroll), the remaining routes are loaded in the background, one per idle slice (requestIdleCallback). Any input, or a navigation the user is waiting for, pauses it until the page is idle again.
A preloaded or eager route never flips router.pending and lands before the next paint: no spinner frame, no layout shift. router.pending is only true for a module or loader that has to be waited for, and the outgoing page stays on screen while it is. A chunk that fails to load surfaces as router.error, never as an unhandled rejection, and can simply be retried.
Router API
#createRouter(table, options?)
table maps a pattern to a loader, or to a nested table. Options:
| Option | Default | |
|---|---|---|
base | '/' | URL prefix the app is served under, such as '/docs'. It only matches at a segment boundary: /docs/intro is inside '/docs', /docsearch is not. URLs outside it match nothing, so start() rejects (your server-side 404) and links to them are left to the browser. |
preload | true | Load the remaining routes' modules in the background once the page is idle. false turns it off. |
idleTimeout | 1000 | Milliseconds without taps, keys, wheel or scroll before preloading starts, or resumes after the user was busy. |
onNavigate | (ctx, kind) => void, called after every resolved navigation in the browser, including the initial one, once the DOM has caught up. kind is 'start', 'push', 'replace', 'pop' (Back, Forward, close()) or 'layer'. The hook for document.title, meta tags, focus, scrolling and analytics. Never called during SSR. |
Returns the router: start(), match(), outlet(), matches(), and every Route member except run(), read through to the active Route. That is the one the last start() returned in the browser, or the one whose run() is executing on the server. Reading a member with neither throws. The route table, the compiled matcher and the module cache live on the router, so they are shared across requests on a server.
router.start(url?)
Resolves url (default: location.href in the browser) to its page modules and returns a Promise<Route>. Rejects when nothing matches and the table has no '*' route. On a server, that is your 404. In the browser it also makes the Route the router's active one, attaches the popstate and delegated click listeners and schedules preloading. A second start() retires the previous Route for you. On the server it touches nothing shared, so render inside route.run().
router.match(url?)
Resolves a URL against the table without loading anything: no import, no history, no listeners, no Route. Returns a RouteContext, or null when the URL is outside base, matches no pattern or, in the browser, is on another origin. Absolute URLs only, since ./ resolution needs an active route.
// 404 before importing a single chunk if (!router.match(request.url)) return new Response(null, { status: 404 }) // "is this link ours?" for a custom link component or a prefetch rule const owned = router.match(href) !== null
router.outlet()
The active route's pages as a list(). Place it once, wherever the pages go. A pushed layer is a later row of it. It is empty before start() resolves and after stop(). On the server it renders the Route whose run() is executing.
router.matches(table)
Renders the view of a second table that the active route's path matches, as a list() you place anywhere. See matches().
Route
Every member but run() is also on the router, which is where an app reads it.
| Member | |
|---|---|
path, pattern, params, search, hash, url | the active match. search is a URLSearchParams. search, hash and url are live. |
pending | a navigation is waiting for a module or a loader |
error | the last failed load, cleared by the next navigation |
data | what the matched page's load() returned, for a server to serialize. A parent's is not on it. |
depth | how many layers are on the stack (1 for an ordinary page) |
go(href, { replace }) | navigate, loading the module if needed. Never rejects: a module that fails, by rejecting or by throwing, lands on error. |
push(href) | open a route as a new layer on top, resolving with what it closes with. Accepts ./. Rejects on no match or a failed module. |
href(path) | prefix a path with base, for a({ href }). Resolves ./ and ../ against the active route. |
stop() | detach every listener, dismiss open layers, empty the outlet. Idempotent. A stopped Route still answers with its last match, and go() does nothing. |
run(fn) | call fn with this Route as the router's active one and return its result: route.run(() => renderToString(App)) on the server. Synchronous only. The Route stops being active when fn returns. |
RouteContext
A page's first argument: path, pattern, params, search, hash, url. It is the context the page was built with. Read router.* for live values. They differ only when the query or hash changed without a rebuild.
params is { [name]: string } by default. A page can type it from its own pattern with Params, which turns '/blog/:slug' into { slug: string } and '/files/*rest' into { rest: string }:
import type { Params, RouteContext } from 'nuclo-router' export default function Post(ctx: RouteContext<Params<'/blog/:slug'>>) { return article(h1(ctx.params.slug)) // slug: string, nothing else exists }
PageProps
A page's second argument. Destructure what the page needs.
| Prop | |
|---|---|
layer | where the page sits in the stack. See Layer below. |
data | what the route's load() returned, or undefined without one. Typed by PageProps<TData>. |
outlet | where a parent page renders its child route. See Nested Routes. |
beforeLeave | registers a guard asked before this page is discarded. See Leave guards. |
Layer
Where a page sits in the stack, and its way out.
| Member | |
|---|---|
depth | 0 for the base page, 1 and up for pushed layers |
push(href) | open another layer from inside a page, same as Route.push() |
close(result?) | close this layer and everything above it, resolving the push() that opened it. No argument means dismissed. A no-op at depth 0. |
DataLoader and withLoad()
DataLoader is (ctx: RouteContext) => TData | Promise<TData>, a page module's optional load export. withLoad(loader, load) makes a route whose load starts beside its chunk's import instead of after it. See Route Loaders.
Types
Every type is a named export of nuclo-router, so a page, a loader or a guard can be typed without importing from a path.
| Type | What it is |
|---|---|
Params<'/blog/:slug'> | the params object for a pattern. RouteParams is the untyped shape, and Match is the matcher's result. |
PageComponent | a page: (ctx, { layer, data, outlet, beforeLeave }) => … |
RouteModule | a page module: the component itself, or { default, load? } as import() yields |
RouteLoader | () => RouteModule | Promise<RouteModule>, optionally carrying a load from withLoad() |
RouteTable | the table createRouter() takes: pattern to loader, or to a nested table |
RouterOptions | { base?, preload?, idleTimeout?, onNavigate? } |
NavigateOptions | { replace? }, the second argument of go() |
NavigationKind | 'start' | 'push' | 'replace' | 'pop' | 'layer', the second argument of onNavigate |
Outlet | the type of outlet() from PageProps |
LeaveGuard | (to) => boolean | void | Promise<…>, what beforeLeave() takes |
MatchView | what a matches() view is: params in, content or import() of a page out |
push() opens a route on top of the current one instead of replacing it, and resolves with whatever that layer closes with. Because the pages are a single list() over the stack, opening a layer is an append. The page underneath is not re-rendered, so its DOM, its focus and its half-filled form are all still there when the layer closes.
That makes the "I need a record that doesn't exist yet" flow work without throwing away the form the user was filling in:
// the page underneath const created = await layer.push<Option>('/options/new') if (created) { options.push(created); selected = created.id; update() }
// the pushed page: (ctx, { layer }) layer.close(created) // resolves the push() above with the new option layer.close() // dismissed: resolves with undefined
Layers nest. A dialog that needs its own dialog calls layer.push() and awaits it, with no reference to the Route.
- The URL is the top layer.
push()adds a history entry, so a layer is linkable and Back closes it, resolving itspush()as dismissed, exactly like Cancel. Back andclose()are one code path:close()records its result and walks history back, and the depth inhistory.stateis the authority. Browsers keep about 50 entries a tab, so after a long walk through layers the entry beneath may be gone: thenclose()closes the layers in place and rewrites the current entry to the page left showing. - An ordinary navigation clears the stack. A link click or
go()is not a layer. It replaces everything and dismisses every open layer, so no caller is left awaiting. - A cold load is a standalone page. The URL names the top layer but not the stack beneath it, so opening a layer's URL directly renders it at depth 1 with
layer.depth === 0and an inertclose(). A pushed route has to make sense on its own. Branch onlayer.depthto offer a link out instead of a dead Cancel button. - The promise cannot survive a reload, because it lives in a closure. Treat the result as a shortcut through a flow that also works the long way round.
- Nothing is left awaiting.
stop()dismisses every open layer, and a layer abandoned by a navigation resolves withundefined.
Leave guards
A page that holds something worth keeping, such as a half-filled form, registers a guard. It is asked before the page is discarded, and returning false (or a promise of it) keeps the page:
import type { PageProps, RouteContext } from 'nuclo-router' export default function Edit(_ctx: RouteContext, { beforeLeave }: PageProps) { let dirty = false beforeLeave(() => !dirty || confirm('Discard your changes?')) return form(input({ onInput: () => (dirty = true) }), …) }
- Only when the page goes. A child navigation under a layout, or a query or hash change that keeps the page, does not ask. A guard lives and dies with its page, so there is nothing to unregister.
- Every way out. A link,
go(), Back and Forward, a layer'sclose(), andgo()to a URL the router does not own (leaving the app, wheretoisnull). A refused Back or Forward is walked back, so the address bar stays on the page. Closing the tab or reloading isbeforeunload's job. tois theRouteContextthe navigation is heading to. Guards run innermost first and stop at the first refusal. One that throws keeps the page.close(result)goes through the guard too, so clear your flag before closing with a result.- The router can only walk back history entries it wrote. A Back or Forward onto one it did not, such as a native
#fragmentlink or another script'spushState, is not asked.
matches() & Sub-routers
#router.outlet() renders the page. router.matches(table) renders something else that depends on where you are, such as a sidebar, breadcrumbs or a toolbar, from its own small table, without loading a route or adding a page:
div( aside( router.matches({ '/': () => nav('Home links'), '/blog/new': () => nav('Drafting tips'), '/blog/:slug': ({ slug }) => nav(`More like ${slug}`), '*': () => import('./Sidebar.ts'), // code-split, rendered once loaded }), ), main(router.outlet()), )
- Same matching as the route table. The patterns, precedence, raw-path matching and
basestripping are the router's own, so a view and a page always agree about the URL. It reads the top layer's path, likerouter.path. - A view is
(params, ctx) => content, whereparamsis typed from its pattern andctxis theRouteContextwith the view'spatternandparams. It may instead returnimport()of a page module, which is called withctxonce it lands. - Nothing matches, nothing renders. Add a
'*'view for a fallback. It is empty beforestart()and afterstop(), like the outlet. - It re-renders only when the route changes, so a view keeps its DOM across anything that keeps the page.
- A lazy view renders nothing until its module arrives. A failed
import()is logged withconsole.error, does not setrouter.error, and is retried on the next navigation. A plainupdate()does not retry. - On the server a lazy view ships nothing, since its module lands on the client. Use a synchronous view for anything the first HTML must contain.
Sub-routers
Routes nest, so a sub-router is usually just a nested table. One router: the section's own '/' page is its chrome, and its children render in that page's outlet(), so the chrome survives navigation within the section:
createRouter({ '/': …, '/docs': docsRoutes, '*': … })
Or give the feature a router of its own, created but never started. The parent owns a single catch-all entry and the child resolves the tail against its own patterns, params and fallback:
// parent table '/docs/*rest': () => import('./docs/Shell.ts'),
const docs = createRouter(docsRoutes, { base: '/docs' }) const hit = docs.match() // { pattern: '/:topic', … } const page = await docsRoutes[hit.pattern]() // the app's own lookup
Never starting it is the point. start() is what attaches the listeners, and two started routers conflict. The trade is that the child owns resolution, so route loaders, which a started router runs, are yours to call. This shape also rebuilds the shell when the tail changes, because the router sees one route and the shell resolves the rest itself. A nested table is what gets you a parent the router keeps mounted, so prefer it unless the feature really needs its own router, base and fallback.
An app can have as many routers as it has areas. The rule that keeps this simple: start exactly one per document, and let every other router stay unstarted, resolving URLs with match(). Only the started router owns the URL, the history and the click listener. The rest are tables you ask questions of — and each area can load on its own clock, with its own spinner.
Combined loading: a chain of loaders
Every page on a matched chain may export load(). On a cold navigation to a child route, the layout's loader and the page's loader run in parallel, and one router.pending covers the whole chain: the previous page stays on screen until the pair resolves, so a half-loaded tree never paints. Each component receives its own data:
'/shop': { '/': () => import('./pages/Shop.ts'), // layout, has a load() './:section': () => import('./pages/Section.ts'), // page, has a load() },
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router' export const load: DataLoader<ShopData> = async () => { await wait(500) // a slow API return { sections: ['toys', 'books', 'music'] } } // the layout gets its own data, and its outlet for the child page export default function Shop(_ctx: RouteContext, { data, outlet }: PageProps<ShopData>) { return div( h1('Shop'), nav(...data.sections.map((s) => a({ href: router.href(`/shop/${s}`) }, s))), main(outlet()), ) }
export const load: DataLoader<SectionData> = async (ctx) => { await wait(900) // slower than the layout's return { section: ctx.params.section, items: catalog[ctx.params.section] } } // the page gets its own data: the two loaders ran beside each other export default function Section(_ctx: RouteContext, { data }: PageProps<SectionData>) { return article(h2(data.section), ul(...data.items.map((i) => li(i)))) }
Both loaders start together, not one after the other. router.pending is true while either one is awaited, and false the rest of the time — a preloaded or eager route never flips it. Moving between sections keeps the layout, so only the section's loader runs again; the layout's data, its DOM and a half-typed input all survive. Re-navigating to the same URL re-runs the leaf's loader and rebuilds its row, exactly as with a single page.
Multiple loading: a second router with its own spinner
An independent area — a sidebar, a tray, a live region — gets a router of its own, created but never started, and its own loading flag. router.pending belongs to the started router and says nothing about this area, so the two can load side by side and either can finish first:
import { createRouter } from 'nuclo-router' // Created but never started: start() is what attaches the history and click // listeners, and only the main router is started. match() attaches nothing. const standIn = () => ({ default: () => div() }) // never rendered const sidebarRouter = createRouter({ '/shop': standIn, '/shop/:section': standIn, '*': standIn, }, { base: '/docs' }) // the same base as the main router export const sidebar = { loading: false, info: null as SidebarInfo | null } export async function refreshSidebar(at: string) { const hit = sidebarRouter.match(at) // the second router resolves if (!hit || (hit.pattern !== '/shop' && hit.pattern !== '/shop/:section')) { sidebar.info = null; update(); return } sidebar.loading = true // its own flag, its own spinner sidebar.info = null; update() const data = await fetchSidebar(hit.params.section) sidebar.loading = false; sidebar.info = data update() }
createRouter(routes, { onNavigate: (ctx, kind) => { announce(kind) refreshSidebar(ctx.url) // every navigation re-resolves the sidebar }, })
The second router is asked, never told: match() reads the URL and returns a context, and nothing about the sidebar ever enters the main route table. onNavigate runs after every resolved navigation, the initial one included, so it is the one place that keeps an independent area in step with the URL.
pendingbelongs to the started router. A never-started router has no Route, nopendingand noerror. The area tracks its own flags, and a second spinner can sit next to the first. In the demo the sidebar loads for 800ms while the route's loaders run for 900ms, so the page can finish before the sidebar or the other way round.match()attaches nothing. Any number of unstarted routers may share the document; each has its own table, base and module cache, and none of them touch history. They cost a table and a matcher.- Its loaders never run. The started router runs the
load()of the pages it matches. A never-started router only resolves, so if its views need data, fetch it yourself — the demo'srefreshSidebar(). If you want loaders, a nested table or a region a page fills keeps that job with the started router. - Guard stale replies. A slow sidebar fetch must not land on the section you navigated away from. Bump a generation counter per refresh and drop replies that no longer match, the way the router itself retires overtaken navigations.
- A lazy
matches()view has nopendingeither. It renders nothing until its module lands. Three loading systems, three independent clocks: the route'spending, your own flags, and a lazy view that simply is not there yet.
Link Handling
#The delegated listener navigates a left-click on an <a href>, including an SVG <a> and clicks on anything nested inside one, and steps aside for everything else: modifier-clicks and middle-clicks, a target other than _self, download, rel="external", cross-origin hrefs, hash-only links on the current page, paths outside base, paths no route matches (so a server-rendered 404 is still reachable), and any event an app handler already called preventDefault() on. Opt a link out explicitly with data-nuclo-router="off".
Scrolling
A pushed navigation preserves the current scroll position. When the new URL has a #hash matching an element on the page, that element is scrolled into view. popstate leaves scrolling to the browser. To land new pages at the top, do it in onNavigate, which says how the navigation happened:
let shown = '' createRouter(routes, { onNavigate: (ctx, kind) => { // Not on Back/Forward (the browser restores those), nor for a query change on the same page. if (kind === 'push' && ctx.path !== shown && !ctx.hash) window.scrollTo(0, 0) shown = ctx.path }, })
Notes
- Nothing is retained. The Route holds the page function and its context, never DOM. Navigating away releases the previous page.
stop()releases the listeners and any in-flight load, and clearspending, so a retired Route never strands a spinner. A module that resolves afterstop()is dropped silently. - Concurrency-safe on the server.
start()returns per-request state andrun()scopes it to one synchronous render, so interleaved requests cannot see each other's route. Only the module cache is shared. The scope ends whenrun()'s function returns.renderToString()is synchronous, so that is enough. An async or streaming render would need the Route carried acrossawaits, whichrun()does not do. - Scroll restoration on
popstateis the browser's (scrollRestoration), not the router's. - Start one router per document. Each
start()adds its own delegatedclicklistener, and a second router'sstart()does not retire the first. Only a secondstart()on the same router does. To resolve a URL against another table, create a router and call onlymatch(). It attaches nothing. An independent area can run its own loading beside the route'spendingthat way. See Multiple Routers & Combined Loading. - Not included, by design: hash and memory history, since the route lives in the pathname and a server must serve every route; two independently routed areas driven by one URL, since a page can fill several regions with
into()but they all belong to the one matched route — an independent area can still keep its own unstarted router and loading, as in Multiple Routers & Combined Loading; and hover preloading, which you get by calling the route's own loader from amouseenter.
Examples
Two runnable examples in the repository complement each other, so every setting is observable somewhere:
examples/router: a client-side tour of the whole API. Every pattern form and precedence rule, the link-handling opt-outs, every Route member with live controls, a sub-router, the layer stack three deep, a relative section mounted at two parents, and route loaders withpendinganderror. Setspreload: falseso those states stay visible.examples/router-ssr:renderToString()per request, hydration that provably claims the server's nodes, real 404 status codes, a non-rootbase, idle preloading you can watch in the network panel, and a loader whose data is handed from the server to the client so hydration does not refetch.
Nuclo has no computed API. Use a plain function, and call it from dynamic values:
let items = ['apple', 'banana', 'cherry'] let filter = '' const visible = () => items.filter((item) => item.includes(filter)) const Fruits = () => div( p(() => `Showing ${visible().length} of ${items.length}`), ul(list(visible, (item) => li(item))), ) filter = 'an' update()
Use plain async/await, and call update() each time the state changes. For a fetch, that means once before the await and once after:
let status: 'idle' | 'loading' | 'done' | 'error' = 'idle' let result: unknown = null async function loadData() { status = 'loading' update() try { result = await fetch('/api/data').then((r) => r.json()) status = 'done' } catch { status = 'error' } update() }
Best Practices
#- Batch changes. Change all the state you need, then call
update()once. - Keep dynamic values fast and pure. All of them run on every
update(). - Use
scope()for small, frequent updates, such as typing or timers. - Render into the document. Nodes that are not in the document when
update()runs stop updating. - Use
when()andlist()for children that change, not a function that returns nodes. - Use
className, notclass, and use() => cx(...)or{ className: () => … }for dynamic classes. - Define styles once, at module level.
- Server rendering: render first, then put
getCssText()in<style id="nuclo-styles">. Pass request data as arguments, and keep HTML comments in the output.
API Index
#Every public function, and where it comes from.
nuclo (global and named export)
update(...scopeIds): run all dynamic values, or those in the given scopes.scope(...ids): give an element scope ids forupdate(id).when(condition, ...content): conditional content, with.when()and.else().list(items, renderItem): one row per item, matched by identity.region(options)andinto(id, ...content): a named place in the tree, and content that fills it from anywhere.on(type, listener, options?): an event listener, oron('mount')/on('destroy').render(App, parent?): build and append.hydrate(App, parent?): take over server HTML.forceUpdate(): call components again and patch the DOM.css(name?, style): generated classes from a style object.cx(...inputs): combine styles, later wins.createCss(theme): themedcss,cx,variants,keyframesandglobalStyle.variants(config): styles from options.keyframes(frames)andglobalStyle(selector, style).
nuclo (named export only)
nuclo/ssr
renderToString(input),renderManyToString(inputs),renderToStringWithContainer(input, tag?, attrs?)andgetCssText().
nuclo/polyfill
- A side-effect import, plus
document,Event,CustomEvent,Node,Element,HTMLElementand theNuclo*classes.
nuclo-router (separate package)
createRouter(table, options?): the router, withstart(),match(),outlet(),matches(),go(),push(),href()andstop().withLoad(loader, load): a route whose data loader starts beside its chunk.- Types:
RouteContext,PageProps,Params,DataLoader,RouteTableand more.
Globals only
- 175 tag builders:
div,span, …,var_, andsvgSvg,pathSvg, …. - Types:
NodeModFn,NodeModLike,ExpandedElementand more. Styling types such asStyleResultcan also be imported from'nuclo'.
Special keys
- Attributes:
className,style,on*handlers,onMountandonDestroy. - Style objects: shorthands, pseudo states,
&selectors,@rules andraw.
Frequently Asked Questions
#Does changing state update the UI?
No. The DOM stays as it is until you call update().
Does Nuclo use signals, proxies or a virtual DOM?
No. State stays plain JavaScript. When a tree is rendered, Nuclo creates real elements and text nodes. update() runs every registered dynamic value, or those inside the given scopes, and patches those same nodes where a value changed. There is no dependency graph and no virtual tree.
Why does my page show "false"?
Booleans render as text. div(ok && span('Done')) shows "false". Use when(() => ok, span('Done')) for children, and ok ? 'Done' : '' for text.
Why doesn't my list row change when I edit the item?
Rows for items that are still in the list are kept, not rendered again. Use dynamic values such as () => todo.text for fields that change. See list().
Why does part of my UI stop updating?
Usually its nodes were not in the document when update() ran. Nuclo stops tracking those nodes, even after you attach them, so render into a container that is in the document. Two other causes: a when() whose condition throws stops updating, and a text function that throws on its first run stays empty. See update().
When should I use forceUpdate()?
Only for rare changes to static values, such as switching the language. For everything else, use update(). See forceUpdate().
How do I put a page into a layout without passing it down as a prop?
Mark the place with region({ id: 'main' }) and have the page return into('main', …). See region() & into().
How do I unmount an app?
Wrap it in when() and switch the condition off. Removing the DOM yourself does not run onDestroy. See Lifecycle.
Does Nuclo include a router?
Not in the core package. Routing ships separately as nuclo-router, with server rendering, hydration, code-split routes and idle preloading. Install it with npm install nuclo nuclo-router. See nuclo-router.
Does Nuclo have effects?
No. onMount and onDestroy run once per element, tied to insertion and removal. Nothing runs again because some state changed.