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.
For those of you tired of being "markup organizers", tired of fighthing against re-render mess, tired of 1GB of node_modules to make a 500kb application, layers and layers of compilation, no control over the end result of your code, Granular brings coding to the engineering level again. Code looks like code on Granular, and when you look at the code you just know what will happen. No need to figure out a one hundred steps "lifecycle".
If your UI should be fast and your code should still look like code, this is for you.
For AI coding assistants: In this repo, for full API and patterns, see GRANULAR_AI_GUIDE.md and ARCHITECTURE.md.
- 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.
@granularjs/core is runtime-only and ships with zero runtime dependencies. The CLI, linter, codemods and project scaffolder live in dedicated packages:
| Tool | Package |
|---|---|
granular umbrella CLI |
@granularjs/cli |
| Linter | @granularjs/lint |
| Codemods (React → Granular) | @granularjs/codemods |
| App scaffolder | @granularjs/create-app |
Breaking change in 3.0.0: the
granularbinary used to ship inside@granularjs/core. It now lives in@granularjs/cli. To keep usinggranular lint|audit|create|migrate|docs, install the umbrella CLI:npm uninstall -g @granularjs/core # if you had it installed globally for the bin npm install -g @granularjs/cli
Granular is more than a runtime - it ships a small, focused toolchain so you can go from "first commit" to "production" without leaving the ecosystem. This is the day-to-day flow we recommend.
- Required: Node.js LTS + a package manager (npm, pnpm or yarn).
- Strongly recommended: the
granular-vscodeextension. It runs the same@granularjs/lintthe CLI uses, so what shows up in your editor is exactly what fails in CI. - For AI assistants: always include
GRANULAR_AI_GUIDE.mdin the agent context. It is the canonical reference for primitives and anti-patterns, written with LLMs in mind.
npm install -g @granularjs/cli
granular --versiongranular is a single dispatcher for the whole ecosystem:
| Command | Purpose | Backed by |
|---|---|---|
granular create |
Scaffold a new app | @granularjs/create-app |
granular lint |
Lint a file or directory | @granularjs/lint |
granular audit |
Project-wide JSON report | @granularjs/lint |
granular migrate |
Apply codemods (e.g. React → Granular) | @granularjs/codemods |
granular docs |
Serve the local module reference | @granularjs/core |
New project
granular create my-app # vanilla tag-functions (recommended default)
granular create my-app --jsx # with @granularjs/jsx
granular create my-app --ssr # with @granularjs/ssr
cd my-app
npm install
npm run devPick the template that matches your team:
- vanilla - zero deps, the most idiomatic, the closest mental model to Granular itself.
- jsx - for teams coming from React; uses
@granularjs/jsxas the JSX runtime. - ssr - when you need server-side rendering or edge delivery.
Existing Granular project
npm install
granular --version # confirm the CLI is availableMigrating from React
granular migrate ./srcDaily ritual while building features:
npm run devrunning in one terminal.- Editor showing diagnostics from
granular-vscodein real time (recommended). - Before committing a chunk of work:
granular lint src/
- When you forget which primitive does what:
granular docs # serves the module reference at http://localhost:<port> - When pair-programming with an AI assistant: after every patch, run
granular lintand feed the output back to the agent. The linter catches almost every bad reactive pattern an LLM might emit.
Add a git hook so anti-patterns never reach main. With husky + lint-staged:
{
"scripts": {
"prepare": "husky install"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "granular lint --max-warnings=0"
}
}npx husky add .husky/pre-commit "npx lint-staged"The minimum recommended pipeline:
# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npx granular lint --format=json --max-warnings=0
- run: npx granular audit --format=json
- run: npm test
- run: npm run build- Run
granular auditperiodically. The JSON report is great PR-comment material and excellent context for AI-driven refactors. - Keep
core,cli,lintandjsxupdated together when crossing a major. - Version
GRANULAR_AI_GUIDE.md(or a project-specific addendum) inside the repo, so humans and IAs onboard from the same source of truth.
A few habits that keep Granular code clean over time:
- Lint-driven development. Linter errors are usually real bugs (
no-state-truthy-and,prefer-when-over-ternary,prefer-list-over-map). Don't suppress them - fix the design. - State lives close to who uses it. Promote to a parent only when 2+ consumers need it. Globals belong in
src/stores/. - Editor = CI = AI. All three consume the same
@granularjs/lint. If your editor is green, CI is green. - Components run once. Any logic that re-runs on its own is a smell - you're probably constructing inside a render path instead of using
compute. - No reactive primitives in raw conditionals.
state && Div(...)is a bug; usewhen(state, () => Div(...))ormatch(...). The linter enforces this. - One file, one purpose. Suggested layout for vanilla projects:
src/ pages/ # route = file components/ # reusable UI stores/ # observableArray, persist, signal services/ # pure I/O (fetch, ws, etc.) main.js
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, ' x2 = ', 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. - Context: share reactive state across a component tree without prop drilling.
- 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, ...). - Each tag accepts any number of arguments in any order: props objects (HTML attributes) and children (text, renderables, signals, state, arrays). Examples:
Div('Test'),Div({ style: { width: '100px' } }, 'Texto', { className: 'teste' }, 'Mais Texto'). - 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 '@granularjs/core';
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..get()and.set()are path-relative: calling them from a nested path resolves from that path, not the root.- Direct mutation of state paths is forbidden (
s.user = ...throws). mutate(optimistic, mutation, options?)supports optimistic updates with rollback.subscribe(target, selector?, listener?, equalityFn?)subscribes to a reactive target with optional selector for fine-grained updates. You get mutable ergonomics with immutable safety. No spread hell, no guesswork.
resolve(value)unwraps any reactive value (signal, state, computed, state path) to its raw current value. Non-reactive values pass through unchanged.computed(input)transforms a props object into a proxy where each property becomes a read-only computed state. Accepts signals, state, or plain objects.
concat(...parts, options):
- Joins primitives and reactive values into a single reactive string.
- Supports conditional tuples:
[state, 'class-name']. - Options:
separator,filterFalsy.
isSignal(value)- true if value is a signal.isState(value)- true if value is a state root.isStatePath(value)- true if value is a state path (e.g.,user.name).isComputed(value)- true if value is a computed state.
readSignal(sig) and setSignal(sig, next, force?):
- Direct read/write access to a signal's value.
setSignalwithforce = truefires subscribers even when the value is unchanged.- Exported for library/advanced use (e.g., custom renderables, context adapters). Prefer
state()for application code.
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.
- Each item is wrapped in
state(item)and each index insignal(index). renderItemreceives(itemState, indexSignal)- reactive wrappers, not raw values.- On
setpatches, the existing state is updated (itemState.set(newValue)), so only the specific DOM nodes bound to changed properties update. No DOM destruction/recreation. - Use state paths for reactive bindings:
Span(item.name)updates only that text node. .get()is path-relative:item.name.get()returns the name value,item.get()returns the item object.- Use
.get()inside event closures for raw values:onClick: () => doSomething(item.id.get()).
Example:
const todos = observableArray([{ text: 'Learn', done: false }]);
list(todos, (todo) => Div(
Span(todo.text), // reactive binding
Span(after(todo.done).compute(d => d ? '✓' : '○')), // reactive computed
Button({ onClick: () => todo.set().done = !todo.done.get() }, 'Toggle')
))
todos.push({ text: 'Build', done: false }); // only adds new DOM
todos[0] = { text: 'Master', done: true }; // only updates bound text nodeswhen(condition, renderTrue, renderFalse):
- Reactive conditional rendering without re‑rendering parents.
conditionaccepts Granular reactive values, predicate functions, or raw truthy/falsy values.
match(sources, predicate, renderTrue, renderFalse):
- Predicate-based conditional rendering with explicit dependencies.
- Re-evaluates the predicate when listed sources change.
- Only swaps branches when the predicate result changes.
- Does not re-run the active render branch while the predicate stays
trueor staysfalse. 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,
});context(defaultValue):
- Shares reactive state across a component tree without prop drilling.
scope(value?)creates a provider level with.get(),.set(), path access, and.serve(renderable).state()returns a reactive state bound to the nearest ancestor provider.- Supports nesting: inner scopes override outer ones without affecting siblings.
- Works with dynamic children (
list(),when()) via mount-time resolution. Context gives you React-like sharing without React-like complexity. No Provider JSX, no useContext - just state that flows.
Example:
import { context, Div, Text, after } from '@granularjs/core'
const themeCtx = context('light')
const ThemeProvider = (...children) => {
const theme = themeCtx.scope('dark')
return theme.serve(Div(...children))
}
const ThemedCard = () => {
const theme = themeCtx.state()
return Div(
{ className: after(theme).compute(t => `card card-${t}`) },
Text('Current theme: ', theme)
)
}
// Usage
ThemeProvider(ThemedCard())Provider controls its own state:
const sizeCtx = context([])
const Table = (...children) => {
const sizes = sizeCtx.scope(['1fr', '2fr', 'auto'])
// sizes.get(), sizes.set(), sizes[0] - full state API
return sizes.serve(Div(...children))
}
const Row = () => {
const sizes = sizeCtx.state()
// sizes is reactive, bound to the nearest Table's scope
return Div({ style: { gridTemplateColumns: after(sizes).compute(s => s.join(' ')) } })
}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 '@granularjs/core';
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' });