nano: explicit reactivity for small apps
@nifrajs/web/nano is the middle lane between a static island and a full framework adapter. It gives you a signal, a derived computed, and a keyed list - enough to build a small app with local state a human edits - and nothing else. No virtual DOM, no template compiler, no auto-tracking. Every reactive edge is a call you write, which is exactly what lets a tool catch the mistakes before runtime.
Why a fourth lane
A framework fails generated code not because it is unfamiliar but because its reactivity is implicit: an effect silently tracks what it read, a stale closure captures the wrong value, a render mismatches hydration - all silent, all runtime. Preact fixes the size of that model (3 KB) but copies its footguns faithfully. nano fixes the failure model for the small-app band: dependencies are an explicit array, list keys are a required argument, cleanups are a returned value. Each of those is a thing a static check can see. Reach for nano when a page needs local state and a keyed list; reach for a framework adapter the moment you need client routing, nested view state, or suspense.
Signals and computed
A signal holds a value; .get() reads it and .set(...) replaces it (deduped with Object.is). A computed derives a value and declares its sources as an explicit deps array - it recomputes when a listed source changes, never by magic. Both are synchronous and framework-free.
import { signal, computed } from "@nifrajs/web/nano"
// State is a signal: reads are .get(), writes are .set(...). Every edge is a call you can see.
const count = signal(0)
// Derived state declares its dependencies EXPLICITLY - the array is the whole contract. It
// recomputes when (and only when) a listed source changes. Object.is-deduped, like signal.
const doubled = computed(() => count.get() * 2, [count])
const off = doubled.subscribe((v) => console.log("doubled is now", v))
count.set(5) // logs: doubled is now 10
off() // unsubscribeThe deps array is the whole contract, and its one failure mode is catchable: a computed whose body reads a signal the array omits will never recompute when that signal changes. nifra check flags exactly that as NF-C023 - the check reads which signals the body calls .get() on and compares them to the array. A framework's auto-tracked effect gives a tool nothing to compare, which is why the equivalent lint elsewhere is a best-effort warning, not a gate.
Bind a signal to the DOM
bind is the one-value edge: it applies your function immediately and on every change, and returns a disposer. Collect the disposer. A bare bind(...) whose return value is discarded leaks its subscription on soft-nav - nifra check flags it as NF-C021.
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { signal, bind } from "@nifrajs/web/nano"
const label = signal("hello")
// bind applies immediately AND on every change; it returns the disposer. COLLECT it - a bare
// bind(...) that drops its return leaks the subscription on soft-nav (nifra check flags NF-C021).
const off = bind(document.querySelector("h1")!, label, (el, v) => { el.textContent = v })
label.set("world") // the <h1> updates synchronously
// ...on teardown: off()Keyed lists with bindList
bindList keeps a container's children in sync with an array by key - a keyed reconcile like a framework's list diff, but you spell the key out. Add, remove, and reorder reuse the matching node (preserving focus and scroll) instead of rebuilding the list. Key by a stable id on the item; keying by the array index reuses the wrong node on reorder, and nifra check flags it as NF-C022.
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { signal, bindList } from "@nifrajs/web/nano"
interface Todo { id: string; text: string; done: boolean }
const todos = signal<Todo[]>([])
// bindList keeps a container's children in sync with an array by KEY - a keyed reconcile, like a
// framework's list diff, but explicit. Add/remove/reorder reuse the right node instead of rebuilding.
const off = bindList(todos, document.querySelector("ul")!, {
key: (t) => t.id, // a STABLE id on the item. Keying by the array index is NF-C022 - reorder breaks.
create: (t) => { const li = document.createElement("li"); li.dataset.id = t.id; return li },
update: (li, t) => { li.textContent = t.text; li.classList.toggle("done", t.done) },
})
todos.set([...todos.get(), { id: crypto.randomUUID(), text: "buy milk", done: false }])
// ...on teardown: off()Async state with resource
resource(fetcher, [deps]) is nano's answer to “suspense” - an async cell whose value is an explicit { status, value, error } union. It runs the fetcher immediately and again whenever a declared dep changes, and it handles the two things people get wrong by hand: the fetcher receives an AbortSignal, and a superseded request is aborted with its late result dropped, so the newest fetch always wins - no stale-response flicker. There is no thrown promise and no magic boundary; it is a value you read or bind like any other.
import { signal, resource } from "@nifrajs/web/nano"
const userId = signal(1)
// resource(fetcher, [deps]) is an async cell: it fetches immediately and again whenever a declared
// dep changes. The fetcher gets an AbortSignal; a superseded fetch is aborted and its late result
// dropped, so the newest request always wins - no stale-response flicker to hand-guard.
const user = resource(async (sig) => {
const res = await fetch(`/api/users/${userId.get()}`, { signal: sig })
return (await res.json()) as { name: string }
}, [userId])
// The value is an explicit union - status is "pending" | "error" | "ready", never a secret undefined.
const off = user.subscribe((s) => {
if (s.status === "ready") console.log("got", s.value.name)
})
userId.set(2) // refetches; the deps array is what NF-C023 checks against the fetcher's .get() reads
// ...on teardown: off()The deps array is checked exactly like computed: a resource whose fetcher reads a signal the array omits won't refetch when it changes, and nifra check flags it as NF-C023. Bind it to the DOM with bindResource, which dispatches on status - one handler per state, ready required, so a still-loading value can never render as a stray undefined.
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { signal, resource, bindResource } from "@nifrajs/web/nano"
const userId = signal(1)
const user = resource(async (sig) => {
const res = await fetch(`/api/users/${userId.get()}`, { signal: sig })
return (await res.json()) as { name: string }
}, [userId])
// bindResource dispatches on status - one handler per state, ready required. Like bind it returns a
// disposer (a bare bindResource(...) that drops it is NF-C021).
const off = bindResource(document.querySelector("[data-user]")!, user, {
pending: (el) => { el.textContent = "Loading…" },
ready: (el, u) => { el.textContent = u.name },
error: (el) => { el.textContent = "Could not load user" },
})
// ...on teardown: off()nano inside an island
nano owns state and DOM edges; an island owns mount and teardown. Put the signals in the enhancer, collect every disposer, and return one cleanup that calls them all. Scaffold this shape with nifra_scaffold { path, variant: "stateful" } on a vanilla project - it emits the golden pattern below.
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { defineIsland, mountIslands } from "@nifrajs/web/islands"
import { signal, computed, bind, bindList } from "@nifrajs/web/nano"
interface Todo { id: string; text: string; done: boolean }
// nano lives INSIDE an island - the island owns mount/teardown, nano owns the state and the DOM edges.
const todos = defineIsland<{ items: Todo[] }>((el, props) => {
const items = signal<Todo[]>(props.items)
const remaining = computed(() => items.get().filter((t) => !t.done).length, [items])
// Collect every disposer; the island's cleanup calls them all (this is what NF-C021 protects).
const cleanups: Array<() => void> = []
cleanups.push(bind(el.querySelector("[data-count]")!, remaining, (n, v) => {
n.textContent = String(v) + " left"
}))
cleanups.push(bindList(items, el.querySelector("[data-list]")!, {
key: (t) => t.id,
create: (t) => { const li = document.createElement("li"); li.dataset.id = t.id; return li },
update: (li, t) => { li.textContent = t.text; li.classList.toggle("done", t.done) },
}))
const add = (text: string) =>
items.set([...items.get(), { id: crypto.randomUUID(), text, done: false }])
void add
return () => { for (const off of cleanups) off() }
})
mountIslands({ todos })The three checks
nano's explicitness is the whole point: because each reactive edge is a visible call, its three mistakes are static lints with a fix recipe - the same closed loop (scaffold → check → fix → verify) that makes the backend AI-safe, now on the frontend.
NF-C021- abind/bindListwhose disposer is discarded. Collect it; call it on teardown.NF-C022- abindListkeyed by the array index. Key by a stable id.NF-C023- acomputedthat reads a signal its deps omit. Add it to the deps array.
All three warn - a false positive must never fail a build - and each carries a fix recipe an agent can apply. Where nano stops (client routing, nested views, suspense), stop adding nano and reach for @nifrajs/web-preact or another framework adapter.