Skip to content

A brief history of Directives

The “directive” — a function attached to a DOM element to add cross-cutting behavior — is one of the oldest abstractions in JavaScript UI work. It predates JSX by years, predates React entirely, and has been quietly rediscovered every framework cycle since 2009. This page traces the pattern honestly: what each contributor brought, what didn’t survive, and where Phaze fits.

yearframeworkshape
2009AngularJSmodule.directive(name, factory)link(scope, el, attrs) — directives ARE components in early Angular
2010Knockout.jsko.bindingHandlers.name = { init, update } — custom data-bind attributes
2014Vue 1.xv-name="value"bind / update / unbind
2016Vue 2v-namebind / inserted / update / componentUpdated / unbind
2016Svelte 1 → 3use:action={value}(node, params) => { update?, destroy? }
2018Soliduse:directive={value} — borrowed from Svelte, JSX-namespaced
2019Litdirective(class) — render-helpers, different category
2019Alpine.jsx-name="..." — directives as the entire authoring surface
2020Vue 3v-namemounted / updated / beforeUnmount / unmounted (lifecycle rename)
2024Vue 3 Vaporv-name retained in compiled mode
2026Phazeuse:name={value}(el, () => value) => void | (() => void); plus phaze:attr={expr} (reactive attributes) and on:event={fn} (native events) — three namespaces, one job each

The original directive system. Released October 2010. The first time the word “directive” was load-bearing in a JS framework.

What it brought

  • module.directive('myThing', factory) — register a directive globally.
  • The link(scope, element, attrs, controller) function — the attachment hook.
  • restrict: 'A' | 'E' | 'C' | 'M' — directives could be attributes, elements, classes, or HTML comments.
  • transclude, template, templateUrl — directives carried template machinery.
  • compile vs link — a two-phase model that nobody else copied.

What didn’t survive

  • The whole AngularJS framework, mostly. Angular 2 (2016) was a rewrite with a completely different mental model.
  • The directive-as-component conflation. Modern Angular separates @Component from @Directive cleanly.
  • restrict: 'M' (HTML-comment directives). Nobody used them.
  • The compile / link two-phase split.
  • Two-way scope binding through = — replaced by explicit input/output binding.

What Phaze carries forward The word “directive” and the idea that some behavior is element-shaped, not component-shaped. The implementation has nothing in common — Phaze’s directive is a 4-line function, not a registry entry with 12 configuration knobs.


The same idea, smaller surface. Knockout’s “custom binding handlers” predated Vue’s directives by four years and were structurally identical.

What it brought

  • ko.bindingHandlers.myBinding = { init, update } — register a binding by name.
  • init(element, valueAccessor, allBindings, ...) — runs once on element creation.
  • update(element, valueAccessor, ...) — runs whenever the bound observable changes.
  • Used as data-bind="myBinding: someValue".

What didn’t survive

  • The data-bind="..." HTML attribute syntax — JSX won.
  • The init + update lifecycle split — frameworks consolidated to “one mount, plus reactive primitives for re-running.”
  • Knockout’s MVVM framing — component-based UI ate it.

What Phaze carries forward Conceptually quite a lot. Knockout’s init(el, valueAccessor) is one rename and one parameter shuffle from Phaze’s directive(el, value). Both attach behavior to an element by name. Both pass a reactive value accessor. Knockout split mount and update into two callbacks; Phaze unifies them — write effect(() => { ... value() ... }) inside the mount and you get the update callback for free, with auto-cleanup.


The mainstream-legitimating directive API. Vue made v-name part of every Vue developer’s daily vocabulary.

Vue 1.x (2014)bind / update / unbind. Directives accepted a value, a string argument (v-on:click), and a string of modifiers (v-on:click.prevent.stop).

Vue 2 (2016) — five hooks: bind (initial setup), inserted (DOM insertion), update (vnode updated), componentUpdated (children resolved), unbind (teardown). Most directives needed only two; the rest existed for niche cases.

Vue 3 (2020) — renamed to align with component lifecycle: created / beforeMount / mounted / beforeUpdate / updated / beforeUnmount / unmounted. Same five-ish concepts, longer names.

What didn’t survive (across the Vue evolution)

  • The five-hook lifecycle. Phaze, Svelte, and Solid all collapsed it to “one function, with cleanup.”
  • componentUpdated as a separate hook from updated — even Vue dropped this in v3.
  • The v-on:click.prevent.stop modifier syntax — neat but not portable; nothing else picked it up.
  • Strict directive registration (Vue.directive('name', def)). Vue 3 supports inline directives via setup; Phaze and Svelte don’t require any registration step.

What Phaze carries forward The name="value" attribute shape (Phaze writes it use:name={value}, but the visual structure is the same). The idea that directives have an argument (e.g. v-on:click) led naturally to on:click and phaze:class in Phaze — same colon-separated namespace.


The cleanest version. Svelte’s use:action is the closest ancestor to Phaze’s directive shape — and to Solid’s use:.

What it brought

  • use:action={value} — JSX-attribute-shaped, value-passed.
  • The action function:
    function action(node, parameters) {
    return {
    update(newParams) { /* runs when parameters change */ },
    destroy() { /* teardown */ }
    }
    }
  • Plain JS, no registration. Import the function; reference it in the markup.
  • Svelte’s compiler emits the call site after node creation, exactly like Phaze does today.

What didn’t survive

  • The update / destroy object-return shape. Svelte 5 still supports it for back-compat, but the modern pattern uses signals/runes inside the action body and an effect-style return.
  • Returning the object literal — a small ceremony Phaze trims by giving the directive a thunk-shaped value argument that re-runs naturally.

What Phaze carries forward Almost everything. Phaze’s use:name={value} is structurally identical to Svelte’s use:action={parameters}. The differences:

  1. Phaze’s value argument is () => T (a thunk). Svelte’s was T plus an update(T) callback. Phaze unifies the two — call value() anywhere inside the directive body to read the current value; wrap in effect(() => ...) to react to changes; cleanup runs through cleanup() from the active scope.
  2. Phaze relies on the runtime’s auto-cleanup (AbortSignal-driven listener removal, cleanup()-based teardown) instead of a destroy return.
  3. Phaze adds JSX type augmentation (JSX.Directives) so use:autofocus={42} is a type error when the directive expects a boolean.

The contract is clean enough that Svelte → Phaze migration is mechanical. The naming (use:) is identical because Solid borrowed it from Svelte and Phaze borrowed it from Solid.


The bridge from Svelte’s use: to JSX. Solid took Svelte’s directive idea and showed it works in a JSX-based runtime.

What it brought

  • use:directive={value} JSX namespace — same shape as Svelte, transplanted into JSX.
  • A typed Directives interface for declaration merging — Phaze copied this.
  • A compiled call site equivalent to directive(node, () => value) — Phaze’s transform is structurally identical.

What didn’t survive (or didn’t propagate)

  • Nothing significant. Solid’s directive API is small and stable; Phaze’s mainly clones it. The differences are in the surrounding ecosystem (DSL aliases, the canonical @madenowhere/phaze-directives package, the TS language-service plugin), not in the contract itself.

What Phaze carries forward The JSX namespace use:. The thunk-shaped second argument. The declaration-merging type augmentation. The post-creation IIFE compile target. Phaze’s design owes more to Solid’s directive surface than to any other ancestor on this page.


A different category. Lit “directives” are render-helpers, not behavior attachers. Worth flagging because the naming overlaps and the role doesn’t.

What it brought

  • class MyDirective extends Directive { update(part, args) { ... } } — a class that controls a Part (a render hole in a template).
  • Used inline inside a tagged template literal — html template + ${myDirective(args)} interpolation.
  • Built-ins like until, repeat, cache, classMap, styleMap.

What’s different from Phaze/Vue/Svelte directives Lit directives produce content into a render slot. They’re closer to render functions or React’s HOC pattern than to Vue’s v-name. Lit also has “async directives” for streaming results.

What Phaze carries forward Nothing directly. Phaze’s directives are not render-helpers; they’re behavior-attachers. The two solve different problems and the names being identical is mostly an accident.

If you’re coming from Lit and need its repeat / cache / classMap patterns: Phaze’s <For> is the keyed-list answer; reactive class={() => ...} covers classMap; there is no cache because the runtime doesn’t re-render trees.


The framework where directives are the entire surface. Alpine builds the whole authoring experience around x-data / x-bind / x-on / x-show / etc.

What it brought

  • x-data="{ count: 0 }" — reactive scope on an element.
  • x-bind:class="..." and x-on:click="..." — Vue-shaped directives but server-render-friendly.
  • A philosophy: small enough to drop in via <script>, no build step, no JSX.

What didn’t survive (in the broader directive lineage) Alpine’s full x- directive set is specific to Alpine and hasn’t propagated. The x-bind: / x-on: namespaces share DNA with Vue’s : / @ shortcuts, but the runtime expression-string evaluation (x-show="user.isAdmin") is bound to Alpine.

What Phaze carries forward Nothing directly — Phaze ships JSX, not HTML-attribute expressions. The relationship is generational: Alpine proved that a directive-first authoring model could work in 2019, which contributed to the broader rehabilitation of directives as a first-class authoring tool.


Phaze isn’t claiming originality on directives. The pattern is older than React. What Phaze contributes is a particular synthesis of three earlier ideas plus one new one:

Carried forward fromWhat
Vue 1 (2014)The name:value colon-separated namespace shape (Phaze: use:name, phaze:attr, on:event)
Knockout (2010)The “register behavior by name, accept a reactive value accessor” core idea
Svelte (2016)The plain-function-no-registration model; use: keyword
Solid (2018)The JSX namespace; JSX.Directives declaration-merging type pattern

What’s distinctly Phaze:

  1. Three namespaces, three jobs, no overlap. phaze:attr is for reactive attributes only. on:event is for native events only. use:directive is for behavior attachments only. Vue’s v-name carries arguments and modifiers and conditionally bound classes; Phaze splits those into separate, purpose-built namespaces. Each namespace’s contract is one sentence long.
  2. Thunk-shaped value argument unifies one-time and reactive use. Svelte 1’s update(newParams) callback and Vue’s componentUpdated lifecycle are both replaced by value() inside the body. Read once for static use; wrap in effect(() => value()) for reactive use. One signature, both modes.
  3. Auto-cleanup by default. listen(el, ...) registers the listener via the active scope’s AbortController; cleanup(fn) registers a teardown with the same scope. No destroy return value to remember; nothing leaks if you forget.
  4. Type-safe directive props. JSX.Directives declaration merging means use:autofocus={42} errors at the call site when the directive expects a boolean. Solid pioneered this; Phaze ships the canonical types in @madenowhere/phaze-directives so importing the package activates them.
  5. A canonical directive package, curated to keep components clean while still composing with signals. @madenowhere/phaze-directives ships the boring set (autofocus, tooltip, longpress, clickOutside) at ~20–30 lines each. The shortlist is deliberate — these are the cross-cutting behaviors that, if written inline, would clutter every component with useEffect-shaped boilerplate (focus management, listener wiring, observer setup, document-level click tracking). Pulling them into directives lets the JSX read as structure-and-state-only while the directive body still calls effect(() => value()) to react to a passed signal. Components stay focused on what they render; directives absorb the imperative DOM glue. Vue’s official directive ecosystem is fragmented across third-party packages with inconsistent contracts; Phaze treats this curated set as a stdlib and rejects directives that don’t earn their keep on this exact axis.
  6. A TS language-service plugin for the IDE-only false positive. use:directiveName doesn’t read like a normal reference, so TS marks the import as unused. @madenowhere/phaze-tsplugin filters the diagnostic. Vue 3 sidesteps this by registering directives globally; Phaze keeps the import explicit and patches the IDE story instead.

What didn’t survive (across the whole lineage)

Section titled “What didn’t survive (across the whole lineage)”

A list of patterns that were tried and shed:

  • Multi-phase directive lifecycle (AngularJS compile/link, Vue 2’s five hooks). Modern frameworks unify on “one function, plus reactive primitives for re-running.”
  • Global directive registration (AngularJS module.directive(...), Vue 1/2 Vue.directive(...)). Svelte/Solid/Phaze just import a function.
  • The init + update callback split (Knockout, Vue 1). Replaced by closure + reactive primitives.
  • HTML-attribute expression strings (Knockout data-bind, Alpine x-show="..."). Lost to JSX where the value is a real JS expression.
  • restrict: 'M' HTML-comment directives (AngularJS). Nobody used them.
  • return { update, destroy } object literals (Svelte 1–4). Replaced by closure + auto-cleanup.
  • Modifiers in the directive name (Vue’s v-on:click.prevent.stop). Neat, not portable; nothing else picked it up.
  • Two-way binding via directive (Vue’s v-model, AngularJS’s =). Phaze deliberately defers this — see Decisions for the rationale.

This isn’t framework triumphalism — every one of those patterns made sense in its time. They turned out to be replaceable as the broader pattern matured.