Granular is a JS-first frontend framework built for performance, clarity, and real control. No template DSL, no VDOM, no magic compile step — just explicit reactivity and direct DOM updates.
If your UI should be fast and your code should still look like code, this is for you.
- JS-first UI: DOM tags are functions (
Div,Span,Button). - Granular updates: only the nodes that change update.
- Explicit reactivity:
signal,state,after,before,set,compute,persist. - No JSX/TSX: no parallel language, no VDOM tree.
- No build required: runs directly in the browser (ESM).
- No dependency pile-up: no 300‑package dependency tree just to render a button.
const App = () => {
const counter = persist(state(0), { key: 'counter' });
before(counter).change((next) => {
return (next <= 10)
})
after(counter).change(() => {
console.log('counter changed')
});
const doubled = after(counter).compute((value) => value * 2);
return Div(
{ style: { fontSize: 20 } },
Span(counter),
Span(' x2 = '),
Span(doubled),
Button({ onClick: () => counter.set(counter.get() + 1) }, 'Increment')
);
};- No virtual DOM: no reconciler, no tree diff, no “render” ceremony.
- No build tax: skip the compile pipeline and ship ESM directly.
- Real performance: update only what changed, not an entire tree.
- Explicit, readable reactivity:
after(...targets)andafter(...targets).compute(...). - Fewer moving pieces: no metaframework, no plugin circus, no “install 738 packages”.
- Functional ergonomics: clean JS with predictable behavior (and no hook rules).
Yes, we are poking the bear — but for a reason. Complexity and over‑abstraction are not features.
- Core runtime: DOM tags, renderables, granular updates.
- State:
state()+ computed values + persistence. - Query/Refetch: caching, dedupe, retries.
- Router: history/hash/memory with guards and transitions.
- Events:
before/afterhooks everywhere (variadic). - SSR:
renderToString+hydratefor server HTML. - WebSockets: client with reconnect + hooks.
const total = after(cart.items, cart.discount).compute((items, discount) => {
return calcTotal(items, discount);
});
const unsub = after(user.name, user.role).change((next) => {
console.log('changed:', next);
});
before(form.values).change((next) => {
if (!next.name) return false;
});- DOM tags are functions (
Div,Span,Button, ...). - Props are applied directly to the real DOM.
- Children accept primitives, renderables, arrays, and observable sources.
- No HTML template parsing, no VDOM. Granular renders real DOM, on demand, with zero template gymnastics. Your UI is JavaScript, nothing else.
Renderableis the base mountable unit.Renderernormalizes values:- primitive →
TextNode Node→ mount directlyRenderable→ mount/unmount lifecycleArray→ flattened list This keeps rendering predictable and composable, without hidden layers.
- primitive →
renderToString(renderable):
- Generates HTML without a DOM.
- Works with all built‑in renderables.
hydrate(target, renderable):
- Attaches UI on the client after SSR.
Example:
const html = renderToString(App({ data }));Elementsexposes all tag functions in a single object.Renderer.normalize()accepts primitives, nodes, renderables, and arrays.
Use node to capture the underlying DOM element into a reactive target.
It accepts a state or signal and is set when the element mounts.
Example:
import { Div, state } from 'granular';
const rootEl = state(null);
Div({ node: rootEl }, 'Hello');Plain functions:
- Components are just functions that return renderables or DOM nodes.
- One‑time construction of the view.
- Updates are granular; no re‑render loop.
- Uses
state()andafter/before/set. Your component runs once. The DOM updates forever. That is the whole point.
signal(value) and state(value):
signalis a small observable primitive.stateprovides proxy paths with.get()/.set()and read‑only bindings.- Direct mutation of state paths is forbidden (
s.user = ...throws). mutate(optimistic, mutation, options?)supports optimistic updates with rollback.subscribe(state, selector)returns a derived, state‑like value. You get mutable ergonomics with immutable safety. No spread hell, no guesswork.
after(...targets) / before(...targets):
- Variadic targets (any change triggers).
change(fn)receives(next, prev, ctx).compute(fn, options)returns a read‑only, state‑like computed value.beforecan cancel by returningfalse.- For arrays,
nextandprevare lazy (next()/prev()).
change() — precise change handling
nextandprevare values for signals/state.- For arrays,
next/prevare functions to avoid heavy snapshots. ctxincludes metadata (for arrays:ctx.patch,prevLength,nextLength).
compute() — derived state with intent
- Same
next/prev/ctxcontract aschange(). - Supports async, debounce, hash, equality checks, and error handling.
Array patch quick reference
insert:{ type, index, items }remove:{ type, index, count, items }set:{ type, index, value, prev }reset:{ type, items, prevItems }
before() — control flow that no other framework has
- Runs before the change is committed.
- Returning
falsecancels the change completely. - This is not a hook. It is a guardrail.
- It lets you enforce business rules, confirm actions, block invalid state, and keep UI clean without hacks.
- Think of it as an interceptor for state: the mutation only happens if you allow it.
after() — deterministic reactions
- Runs after the change is applied.
- Great for side effects, analytics, syncing, or derived updates.
- No re-render, no virtual tree — just a direct reaction to the exact change.
after(...targets).compute(fn, options) and before(...targets).compute(fn, options):
- Recomputes when any target changes.
fn(next, prev, ctx)for a single target.fn(nextList, prevList, ctxList)for multiple targets.- Supports async functions (last‑write‑wins).
- Returns a read‑only, state‑like value with
.get()and bindings. This is how you build reactive values without re-rendering anything.
Options:
debouncedelayhash(...args)skip if unchangedequals(prev, next)skip if unchangedonError(err)for sync/async errors
observableArray(initial):
- Emits patches (
insert,remove,set,reset). - Supports
before()/after()hooks.
list(items, renderItem):
- Efficient list rendering from observable arrays, signals, or state.
when(condition, renderTrue, renderFalse):
- Reactive conditional rendering without re‑rendering parents. Granular treats lists as live data structures, not as arrays you re‑map on every tick.
virtualList(items, options):
- Optional fixed
itemSize(measured automatically if omitted). - Supports
direction: 'vertical' | 'horizontal'. - Viewport size is derived from the parent element.
- Only visible items are rendered (overscan supported).
Example:
virtualList(rows, {
render: (row) => Row(row),
itemSize: 48,
direction: 'vertical',
overscan: 2,
});Horizontal example (auto size):
virtualList(cards, {
render: (card) => Card(card),
direction: 'horizontal',
overscan: 3,
});Granular does not need a separate store type. Any state() can be your global store.
Example (singleton module store):
// user.store.js
export const userStore = state({ users: [] });
export const addUser = (user) => userStore.set().users = userStore.get().users.concat(user);
export const removeUser = (id) =>
userStore.set().users = userStore.get().users.filter((u) => u.id !== id);Selectors:
const users = subscribe(userStore, (s) => s.users);QueryClient:
- Cache per key
- Dedupe in‑flight requests
- Retry with backoff
staleTime,cacheTimeinvalidateandrefetch- Refetch on focus/reconnect
- Abortable fetch via
AbortController - Service factory with endpoint maps and middlewares Server state is not special. It is just state with guarantees.
Service example:
const userService = queryClient.service({
baseUrl: '/api',
middlewares: [authMiddleware],
endpoints: {
getUsers: { path: '/users', method: 'GET', map: UserDTO.from },
getUser: { path: '/users/:id', method: 'GET', map: UserDTO.from },
createUser: { path: '/users', method: 'POST', map: UserDTO.from },
},
});
const user = await userService.getUser({
params: { id: 1 },
query: { active: true },
headers: { 'X-Trace': '1' },
});Router / createRouter:
- History, hash, and memory modes
- Guards, redirects, loaders
- Transition hooks
- Scroll restoration
- Safe path matching with priorities
- Nested routes with
children - Layouts via
layout(outlet, ctx) - Query syncing via
router.queryParameters()Navigation stays declarative, but the runtime stays in your control.
Example:
const AppLayout = (outlet) => Div(
Sidebar(),
Div({ className: 'content' }, outlet)
);
const SettingsLayout = (outlet) => Div(
H2('Settings'),
outlet
);
const router = createRouter({
mode: 'history',
routes: [
{
path: '/',
layout: AppLayout,
children: [
{ path: '', page: Home },
{ path: 'dashboard', page: Dashboard },
{
path: 'settings',
layout: SettingsLayout,
children: [
{ path: '', page: SettingsHome },
{ path: 'profile', page: Profile },
{ path: 'billing', page: Billing },
],
},
],
},
],
});Query parameters:
const q = router.queryParameters({ replace: false, preserveHash: true });
Input({
value: q.term,
onInput: (ev) => q.set().term = ev.target.value,
});
Button({ onClick: () => q.set().page = 1 }, 'Reset page');EventHub:
- Fluent
before()/after()hooks - Dynamic event names via Proxy One event system, used everywhere. Predictable and powerful.
persist(state, options):
- Returns the same target for chaining.
- Hydrates first, then subscribes and saves.
- Default serializer drops functions/symbols.
reconcile(snapshot)can rebuild non‑serializable fields. Your app survives refreshes without manual glue code.
Example:
const profile = persist(state({ name: 'Ana', format: (v) => v.toUpperCase() }), {
key: 'profile',
reconcile: (snap) => ({ ...snap, format: (v) => v.toUpperCase() }),
});form(initial) returns:
values,meta,errors,touched,dirty(state‑like)validators(Set withadd/delete/clear)reset()restores initial snapshot
Validators contract:
fn(values)returnstrue | false | string | object | Promise<...>true/undefined→ okfalse→ form error (_form = true)string→ form error messageobject→ field errors merged by key Forms stop being a framework within the framework. This is just state, done right.
Inputs accept a format prop that can be a string pattern, a regex, a formatter function, or a config object.
Formatting returns { value, visual, raw } and supports mode:
both(default): state stores formatted value, input shows formatted visualvalue-only: state stores formatted value, input shows rawvisual-only: state stores raw, input shows formatted visual
Pattern tokens:
ddigitaletter*alphanumericsnon-alphanumeric
Example:
import { Input, state } from 'granular';
const phone = state('');
Input({
value: phone,
format: { pattern: '(ddd) ddd-dddd', mode: 'visual-only' },
});state.mutate(optimistic, mutation, options?):
- Applies the optimistic change immediately.
- Rolls back automatically on error.
- Optional
rollbackandclonefor control.
Example:
await userState.mutate(
() => userState.set().name = 'Guilherme',
() => userService.saveUser(userState.get())
);ErrorBoundary({ fallback, onError }, child):
- Catches runtime errors inside a subtree.
- Renders the fallback when an error happens.
onErrorreceives the error and context.
Example:
ErrorBoundary(
{ fallback: () => Div('Ops'), onError: (err) => console.error(err) },
() => Div('OK')
);portal(target, content):
- Renders UI outside the normal DOM hierarchy.
targetcan be a selector or a DOM element. Portals are how you build modals, toasts and overlays without fighting layout or z‑index wars. Portals are renderables: they must exist in the render tree to mount, and they unmount when removed from the tree.
Example:
portal('#overlay', () => Div({ className: 'modal' }, 'Hello'));Controlled usage (recommended):
const open = state(false);
const App = () => Div(
Button({ onClick: () => open.set(true) }, 'Open'),
when(open, () =>
portal(() => Div(
{ className: 'modal' },
Button({ onClick: () => open.set(false) }, 'Close')
))
)
);createWebSocket(options):
- Auto‑connect with reconnect support.
before/afterhooks formessageandsend.- Reactive state via
ws.state().
Example:
const ws = createWebSocket({ url: 'wss://example.com' });
ws.after().message(({ data }) => {
console.log('message', data);
});
ws.send({ type: 'ping' });