A glowing monitor showing reactive code with a relaxed red maine coon cat resting nearby.

ArrowJS: Reactivity Without the Framework Tax

The Orange Cat
The Orange Cat

Most modern UI frameworks ask you to buy into a whole world before you can render a button: a virtual DOM, a build pipeline, a component compiler, and a sprawling ecosystem of conventions. ArrowJS takes the opposite bet. It is a tiny, dependency-free, type-safe reactive UI runtime built entirely from platform primitives you already know: JavaScript modules, functions, tagged template literals, and the real DOM. No virtual DOM, no compiler, no special templating language, and no required build step.

Created by Justin Schroeder (the same person behind FormKit), @arrow-js/core weighs in at roughly 3.5kB minified and gzipped, ships zero runtime dependencies, and offers fine-grained reactivity where only the precise template "slots" that depend on changed data are re-executed. It sits conceptually alongside SolidJS and Lit in the no-VDOM, signal-like family, but with an even smaller API surface and no need to run anything through a build tool. That makes it a natural fit for embeddable widgets, micro-frontends, performance-sensitive corners of larger apps, and increasingly, runtime-generated UI assembled on the fly.

Why ArrowJS Exists

The pitch is best understood through the problems it sidesteps. React, Vue, and Angular bundle a virtual DOM, a reconciler, and a sizeable runtime; for a small widget, that is a lot of weight to carry. Svelte and Solid remove the virtual DOM but reintroduce a compiler, so you cannot simply drop a component into a <script> tag and have it run. ArrowJS aims squarely at the gap between those worlds: reactive, declarative UI with direct DOM updates and no build step, all for under 4kB.

Its philosophy is to embrace the platform rather than abstract it away. Reactive tracking is built on JavaScript Proxies, templates are native tagged template literals, and the DOM is treated as a first-class citizen rather than something to hide behind a reconciler. The entire public API is a handful of functions, which the project leans into hard for its newer "framework for the agentic era" positioning: there is so little surface area that the whole documentation reportedly fits in a tiny fraction of a large language model's context window.

What You Get

  • Fine-grained, slot-level reactivity. No component-level re-render and no diffing pass. When a value changes, only the expressions that read it re-run.
  • Direct DOM updates. There is no virtual DOM and no reconciliation step between your data and the page.
  • Zero build step. It runs straight from native ES modules. Add a bundler later only if you want hot reloading.
  • Type-safe by design. The codebase is TypeScript-first, so editors give you real autocompletion and type checking.
  • No runtime dependencies and a footprint around 3.5kB.
  • Keyed list reconciliation through a .key() helper that preserves and reorders existing DOM nodes.
  • A modular 1.0 architecture with optional packages for an async component runtime, server-side rendering and hydration, and a WebAssembly-backed sandbox for isolated code execution.

Getting It Into Your Project

You can install just the core package, which is all you need for the examples below.

npm install @arrow-js/core
yarn add @arrow-js/core

If you would rather start from a full Vite-powered project with dev ergonomics already wired up, there is a scaffolder:

pnpm create arrow-js@latest arrow-app

Hello, Reactive World

Three functions carry almost everything you will do in ArrowJS: reactive to create observable state, html to build templates, and the crucial idea that reactive expressions are functions.

import { reactive, html } from '@arrow-js/core'

const data = reactive({ name: 'Ada' })

const app = html`<p>Name: ${() => data.name}</p>`

app(document.getElementById('app'))

Two things deserve attention here. First, reactive wraps a plain object in a Proxy so ArrowJS can track which properties get read and written. Second, the template returned by html is itself a function: you call it with a mount target to render it into the page.

Now the single most important rule in all of ArrowJS. Notice that the name is interpolated as ${() => data.name}, wrapped in an arrow function. That wrapper is what makes the slot reactive. Compare the two forms:

html`<p>${data.name}</p>`        // static: read once, never updates
html`<p>${() => data.name}</p>`  // reactive: re-runs when name changes

A bare ${data.name} reads the value once at render time and never again. Wrapping it in () => tells ArrowJS to track the read and re-run only that one expression when name changes. Forgetting the wrapper is the classic ArrowJS mistake; the value shows up fine on first paint and then stubbornly refuses to update.

Events and Reactive Attributes

Templates handle events with an @event syntax that should feel familiar if you have used Vue. The handler is, again, an expression inside the template.

import { reactive, html } from '@arrow-js/core'

const state = reactive({ count: 0 })

html`
  <button @click="${() => state.count++}">
    Clicked ${() => state.count} times
  </button>
`(document.body)

Clicking the button mutates state.count, and because the text slot reads that property through a function, ArrowJS surgically updates just that text node. The button element itself is never recreated.

Attributes work the same way, and ArrowJS adds a convenient touch: returning false from an attribute expression removes the attribute entirely rather than rendering the string "false".

const form = reactive({ saving: false })

html`
  <button disabled="${() => form.saving}">
    ${() => (form.saving ? 'Saving…' : 'Save')}
  </button>
`(document.body)

When form.saving flips to true, the disabled attribute appears; when it returns to false, the attribute vanishes. No conditional string juggling required.

Rendering Lists Without Thrashing the DOM

Lists are just arrays of templates mapped from reactive data. The catch is that ArrowJS needs a way to know which DOM node corresponds to which item, especially when the array reorders. That is what .key() is for.

import { reactive, html } from '@arrow-js/core'

const data = reactive({
  todos: [
    { id: 1, text: 'Embrace the platform' },
    { id: 2, text: 'Skip the build step' },
  ],
})

html`
  <ul>
    ${() =>
      data.todos.map((todo) =>
        html`<li>${() => todo.text}</li>`.key(todo.id)
      )}
  </ul>
`(document.body)

By attaching .key(todo.id) to each item template, you tell ArrowJS to preserve and reorder existing <li> nodes instead of tearing them down and rebuilding them. The outer () => keeps the whole list reactive, so adding, removing, or reordering todos updates the DOM with minimal churn.

Stable Components With Local State

For anything beyond a one-off snippet, the modern 1.0 API offers a first-class component() primitive. Its defining property is that the component function is not re-run on every parent update. ArrowJS keeps the instance stable and simply retargets its props, which means local reactive state survives parent re-renders.

import { component, html, reactive } from '@arrow-js/core'

const Counter = component((props) => {
  const local = reactive({ clicks: 0 })

  return html`
    <button @click="${() => local.clicks++}">
      Root: ${() => props.count} | Local: ${() => local.clicks}
    </button>
  `
})

html`${Counter()}`(document.body)

A word of caution that mirrors the () => rule: read props lazily inside expressions, as in ${() => props.count}. If you destructure props at creation time, you freeze them to their initial values and lose reactivity. Treat props as something you reach into when an expression runs, not something you grab up front.

Side Effects With watch()

Not every reactive consequence belongs inside a template. For effects that live outside the DOM, such as logging, syncing to storage, or calling an API, there is watch(). It runs a function, automatically tracks whatever reactive properties that function reads, and re-runs when any of them change.

import { reactive, watch } from '@arrow-js/core'

const cart = reactive({ price: 25, quantity: 10, logTotal: true })

watch(() => {
  if (cart.logTotal) {
    console.log(`Total: ${cart.price * cart.quantity}`)
  }
})

There is also a two-argument form that separates a getter from its effect, which is handy when you want to compute a value and act on it only when it actually changes:

watch(
  () => (cart.logTotal ? cart.price * cart.quantity : null),
  (total) => total !== null && console.log(`Total: ${total}`)
)

Watchers created inside a component clean themselves up automatically when that component unmounts, so you generally do not have to manage their lifecycle by hand.

Modular Power: SSR, Hydration, and a WASM Sandbox

While @arrow-js/core is enough to build interactive UI, the 1.0 release splits additional capabilities into focused packages. @arrow-js/framework provides an async component runtime with boundaries, @arrow-js/ssr handles server-side rendering and payload serialization with client hydration, and @arrow-js/sandbox is the most distinctive of the bunch.

That sandbox runs code inside a WebAssembly-backed QuickJS virtual machine with an isolated DOM and restricted capabilities. The motivation ties directly into ArrowJS's newer positioning as a runtime for AI-assembled interfaces: if an agent is generating and executing UI code at runtime, you want a way to run that code safely. The sandbox lets you do exactly that, isolating untrusted or generated code from the host page. Combined with the minuscule API surface, the bet is that a runtime built from plain functions, template literals, and direct DOM operations is something both humans and coding agents can reason about without a pile of framework-specific lore to trip over.

When to Reach for ArrowJS

ArrowJS shines when you want reactive, declarative UI with zero build overhead and a tiny footprint: embeddable components, micro-frontends, demos, performance-sensitive widgets, or runtime-generated interfaces. If you admire Solid's fine-grained, no-VDOM model but would rather skip the compiler, ArrowJS delivers a similar mental model at the cost of a little explicit () => ceremony.

It is worth being honest about the tradeoffs. The ecosystem is small. There is no official router, no sprawling component library, and a thinner pool of community answers than you would find around React or Vue. The reactivity is explicit and runtime-based rather than compiler-magic, so the () => wrapper and lazy prop reads are discipline you have to internalize. And while no build step is required, in practice many teams still add Vite for hot module reloading, trading a bit of purity for developer comfort.

For large teams that need a mature ecosystem, deep tooling, and a big hiring pool, the established frameworks remain the safer call. But if you want reactivity without the framework tax, ArrowJS is a remarkably small, sharp tool that does a lot with very little. Sometimes the best abstraction is barely an abstraction at all.