Migrating from Alpine.js
Alpine.js is WildflowerJS's closest relative. Both are attribute-based, no-build-step frameworks that enhance HTML with reactive behavior. WildflowerJS adds proper component lifecycle, stores with subscriptions, cached computed properties, keyed list reconciliation, and scales better for larger applications.
x-* prefixes for data-* equivalents. Your HTML structure stays remarkably similar.
Mental Model Shift
Both frameworks share the same core idea: enhance HTML with attributes instead of replacing it with JSX or custom file formats. But they diverge in how they organize logic and scale.
| Concept | Alpine.js | WildflowerJS |
|---|---|---|
| Philosophy | "Sprinkle" interactivity on existing pages | Scales from sprinkles to full SPAs |
| State & logic | Inline in x-data attribute |
Separate JS definitions (clean separation of concerns) |
| Getters / computed | Re-evaluate on every access | Cached, only recalculates when dependencies change |
| List rendering | x-for with template expressions |
data-list with keyed reconciliation |
| Template element | Uses <template> |
Uses <template> (same concept) |
| Component reuse | Alpine.data() or inline | Named components with full lifecycle |
| Global state | Alpine.store() |
wildflower.store() with subscriptions |
| Scaling path | Limited, designed for enhancement | Full SPA architecture with routing, nested components, SSR |
<template> elements, so the HTML structure of your Alpine components translates almost directly to WildflowerJS. The main work is extracting inline JavaScript into component definitions.
Attribute Mapping
A direct translation table for every Alpine directive:
| Alpine.js | WildflowerJS | Notes |
|---|---|---|
x-data="{ state }" |
data-component="name" |
State defined in JS, not inline |
x-text="expr" |
data-bind="prop" |
Text content binding |
x-html="expr" |
data-bind-html="prop" |
Raw HTML binding |
@click="expr" |
data-action="method" |
Logic moves to named JS methods |
@click.prevent |
data-action="method" |
Event is passed to method; call event.preventDefault() there |
@keydown.enter="expr" |
data-action="keydown.enter:method" |
Event modifiers use event.key:method syntax |
x-model="prop" |
data-model="prop" |
Two-way binding, nearly identical |
x-show="cond" |
data-show="cond" |
CSS display toggle, nearly identical |
x-if="cond" |
data-render="cond" |
DOM insertion/removal |
x-for="item in items" |
data-list="items" + <template> child |
Template goes inside the list element |
:class="expr" |
data-bind-class="expr" |
Dynamic class binding |
:style="expr" |
data-bind-style="expr" |
Dynamic style binding |
x-init |
init() lifecycle hook |
Defined in component JS definition |
x-effect |
Computed properties or init() |
Derived values use computed; side effects use init() |
Alpine.store() |
wildflower.store() |
Very similar API |
$store.name |
$name.prop or subscribe |
Cross-entity access in HTML, or subscribe pattern in JS |
Side-by-Side: Todo List
The best way to see the differences is a complete example. Here's the same todo app in both frameworks:
Alpine.js
<div x-data="{
todos: [],
newTodo: '',
get remaining() {
return this.todos.filter(t => !t.done).length;
},
addTodo() {
if (!this.newTodo.trim()) return;
this.todos.push({
id: Date.now(),
text: this.newTodo,
done: false
});
this.newTodo = '';
},
removeTodo(index) {
this.todos.splice(index, 1);
}
}">
<h3>Todos (<span x-text="remaining"></span> remaining)</h3>
<input x-model="newTodo" @keydown.enter="addTodo()" />
<button @click="addTodo()">Add</button>
<ul>
<template x-for="(todo, index) in todos" :key="todo.id">
<li>
<input type="checkbox" x-model="todo.done" />
<span x-text="todo.text"
:style="{ textDecoration: todo.done
? 'line-through' : 'none' }"></span>
<button @click="removeTodo(index)">×</button>
</li>
</template>
</ul>
</div>
Everything — state, getters, methods — lives inline in the HTML attribute.
WildflowerJS
<div data-component="todo-app">
<h3>Todos (<span data-bind="remaining">0</span> remaining)</h3>
<input data-model="newTodo"
data-action="keydown.enter:addTodo" />
<button data-action="addTodo">Add</button>
<ul data-list="todos" data-key="id">
<template>
<li>
<input type="checkbox" data-model="done" />
<span data-bind="text"
data-bind-style="{ textDecoration: done ? 'line-through' : 'none' }"></span>
<button data-action="removeTodo">×</button>
</li>
</template>
</ul>
</div>
wildflower.component('todo-app', {
state: { todos: [], newTodo: '' },
computed: {
remaining() {
return this.todos.filter(t => !t.done).length;
}
},
addTodo() {
if (!this.newTodo.trim()) return;
this.todos.push({
id: Date.now(),
text: this.newTodo,
done: false
});
this.newTodo = '';
},
removeTodo(event, element, details) {
this.todos.splice(details.index, 1);
}
});
HTML is clean markup. Logic lives in a separate JS definition.
- Alpine puts state inline in
x-data; WildflowerJS uses a nameddata-componentwith JS definition - Alpine's
get remaining()re-evaluates on every access; WildflowerJS'scomputedcaches the result - Alpine's
x-foruses(todo, index) in todos; WildflowerJS'sdata-listgets index from the actiondetailsparameter - The
<template>in WildflowerJS goes inside thedata-listelement, not as a sibling
Pattern-by-Pattern Migration
Component Definition
Alpine inlines everything in x-data. WildflowerJS separates markup from behavior.
Alpine
<div x-data="{
count: 0,
get doubled() { return this.count * 2; },
increment() { this.count++; }
}">
<span x-text="doubled"></span>
<button @click="increment()">+</button>
</div>
WildflowerJS
<div data-component="counter">
<span data-bind="doubled"></span>
<button data-action="increment">+</button>
</div>
wildflower.component('counter', {
state: { count: 0 },
computed: {
doubled() { return this.count * 2; }
},
increment() { this.count++; }
});
State
Alpine's state is an inline object. WildflowerJS uses a state property in the component definition.
Alpine
<div x-data="{ name: '', email: '', agreed: false }">
...
</div>
WildflowerJS
wildflower.component('signup-form', {
state: {
name: '',
email: '',
agreed: false
}
});
<div data-component="signup-form">...</div>
Computed / Getters
Alpine uses JavaScript getter syntax that re-evaluates on every access. WildflowerJS computed properties are cached and only recalculate when their dependencies change.
Alpine
<div x-data="{
items: [],
get total() {
// Runs EVERY time 'total' is read
return this.items.reduce((s, i) => s + i.price, 0);
},
get expensive() {
return this.items.filter(i => i.price > 100);
}
}">
<span x-text="total"></span>
</div>
WildflowerJS
wildflower.component('cart', {
state: { items: [] },
computed: {
// Cached! Only re-runs when items changes
total() {
return this.items.reduce((s, i) => s + i.price, 0);
},
expensive() {
return this.items.filter(i => i.price > 100);
}
}
});
Event Handling
Alpine allows inline JavaScript expressions in event handlers. WildflowerJS uses named methods.
Alpine
<!-- Inline expression -->
<button @click="count++">+</button>
<!-- Method call -->
<button @click="handleSubmit()">Submit</button>
<!-- With modifiers -->
<form @submit.prevent="save()">
<button @click.stop="close()">X</button>
<!-- Key modifiers -->
<input @keydown.enter="search()" />
<input @keydown.escape="clear()" />
WildflowerJS
<!-- Named method -->
<button data-action="increment">+</button>
<!-- Same pattern -->
<button data-action="handleSubmit">Submit</button>
<!-- Modifiers in the method -->
<form data-action="submit:save">
<button data-action="close">X</button>
<!-- Key modifiers -->
<input data-action="keydown.enter:search" />
<input data-action="keydown.escape:clear" />
// Handle event modifiers in JS
save(event) {
event.preventDefault();
// save logic...
},
close(event) {
event.stopPropagation();
// close logic...
}
Conditional Rendering
Both frameworks have show/hide and insert/remove patterns. The syntax is nearly identical.
Alpine
<!-- CSS toggle (stays in DOM) -->
<div x-show="isOpen">Dropdown content</div>
<!-- DOM insertion/removal -->
<template x-if="isLoggedIn">
<div>Welcome back!</div>
</template>
WildflowerJS
<!-- CSS toggle (stays in DOM) -->
<div data-show="isOpen">Dropdown content</div>
<!-- DOM insertion/removal -->
<div data-render="isLoggedIn">Welcome back!</div>
x-if requires a wrapping <template> element. WildflowerJS's data-render goes directly on the element you want to conditionally render. No wrapper needed.
List Rendering
Both use <template> elements, but the structure differs.
Alpine
<ul>
<template x-for="item in items" :key="item.id">
<li>
<span x-text="item.name"></span>
<span x-text="item.price"></span>
</li>
</template>
</ul>
Template is a sibling/child of the list container. Key is on the template.
WildflowerJS
<ul data-list="items" data-key="id">
<template>
<li>
<span data-bind="name"></span>
<span data-bind="price"></span>
</li>
</template>
</ul>
Template goes inside the data-list element. Key is on the list container. No item. prefix needed in bindings.
data-key enables efficient DOM reuse when items are reordered, inserted, or removed, similar to React's key prop but with true keyed reconciliation instead of Alpine's full re-render.
Forms
Two-way binding is nearly identical between the frameworks.
Alpine
<input type="text" x-model="username" />
<textarea x-model="bio"></textarea>
<select x-model="role">
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<input type="checkbox" x-model="agreed" />
WildflowerJS
<input type="text" data-model="username" />
<textarea data-model="bio"></textarea>
<select data-model="role">
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<input type="checkbox" data-model="agreed" />
Stores
Both frameworks have a global store concept, but WildflowerJS adds declarative subscriptions with automatic cleanup.
Alpine
Alpine.store('cart', {
items: [],
get total() {
return this.items.reduce((s, i) => s + i.price, 0);
},
add(item) {
this.items.push(item);
}
});
<!-- Access in any component -->
<span x-text="$store.cart.total"></span>
<button @click="$store.cart.add(item)">Add</button>
WildflowerJS
wildflower.store('cart', {
state: { items: [] },
computed: {
total() {
return this.items.reduce((s, i) => s + i.price, 0);
}
},
add(item) {
this.items.push(item);
}
});
<!-- Access in any component -->
<span data-bind="$cart.total"></span>
// Or subscribe in a component definition
wildflower.component('checkout', {
subscribe: { cart: ['items'] },
onStoreUpdate(store, path, newVal) {
// React to store changes
}
});
Scaling Up
Alpine is designed for "sprinkling" interactivity. WildflowerJS has the architecture to scale from sprinkles to full SPAs.
Alpine (typical usage)
<!-- Individual interactive widgets -->
<div x-data="{ open: false }">
<button @click="open = !open">Menu</button>
<nav x-show="open">...</nav>
</div>
<div x-data="searchWidget()">...</div>
<div x-data="cartWidget()">...</div>
<!-- No routing, no nesting, no SSR -->
WildflowerJS (full SPA)
<!-- App shell with routing -->
<div data-component="app-shell">
<nav data-component="main-nav">...</nav>
<main data-route="/dashboard">
<div data-component="dashboard">
<div data-component="stats-panel">...</div>
<div data-component="activity-feed">...</div>
</div>
</main>
</div>
<!-- Nested components, routing, stores, SSR -->
Common Gotchas
No Inline State
State and logic live in a JavaScript file, not in HTML attributes. There is no equivalent to x-data="{ count: 0 }" with inline state. Always define a named component with wildflower.component('name', { state: {...} }).
No Inline Expressions in Actions
Alpine lets you write @click="count++". WildflowerJS requires named methods:
<!-- Alpine -->
<button @click="count++">+</button>
<button @click="items.push({id: Date.now()})">Add</button>
<!-- WildflowerJS -->
<button data-action="increment">+</button>
<button data-action="addItem">Add</button>
Computed Properties Are Cached
Unlike Alpine getters that re-run every time they are accessed, WildflowerJS computed properties only recalculate when their dependencies change. This is a feature, not a bug. It means expensive computations like filtering or reducing large arrays happen much less often.
List Template Is a Child, Not a Sibling
The <template> element goes inside the data-list element:
<!-- Alpine: template is a direct child of the container -->
<ul>
<template x-for="item in items"><li x-text="item.name"></li></template>
</ul>
<!-- WildflowerJS: data-list goes on the container, template inside -->
<ul data-list="items">
<template><li data-bind="name"></li></template>
</ul>
No $refs or $dispatch
Alpine provides magic properties like $el, $refs, and $dispatch. WildflowerJS has its own this.$el(selector), a jQuery-like DOM helper for querying within the component (see DOM Helpers). There are no $refs or $dispatch equivalents. Instead, action methods receive the event and element as parameters: myMethod(event, element, details). For cross-component communication, use stores with subscriptions or the $name.prop cross-entity access syntax in HTML.
Component Registration Is Required
You must call wildflower.component('name', {...}) before using data-component="name" in HTML. Alpine's x-data works inline without any prior registration. Think of WildflowerJS components like Vue or React components. They must be defined before they are used.
Scaling Beyond Alpine
If you chose Alpine because you wanted something lightweight and buildless, WildflowerJS gives you the same starting point with room to grow. Here are the capabilities you gain:
Component Lifecycle
Alpine has x-init but no destroy hook. WildflowerJS provides proper lifecycle management:
wildflower.component('live-feed', {
state: { messages: [] },
init() {
// Runs when component mounts
this._ws = new WebSocket('wss://api.example.com/feed');
this._ws.onmessage = (e) => {
this.messages.push(JSON.parse(e.data));
};
},
destroy() {
// Runs when component unmounts: no memory leaks
this._ws.close();
}
});
Store Subscriptions
Alpine stores are global reactive objects. WildflowerJS stores add declarative subscriptions with automatic cleanup:
wildflower.component('notification-badge', {
state: { unreadCount: 0 },
subscribe: {
notifications: ['items'] // Watch notifications store
},
onStoreUpdate(storeName, path, newValue) {
if (storeName === 'notifications') {
this.unreadCount = newValue.filter(n => !n.read).length;
}
}
// Subscription is automatically cleaned up on component destroy
});
SPA Routing
Alpine has no built-in routing. WildflowerJS includes a full RouteManager:
<nav data-component="main-nav">
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
<main>
<div data-route="/dashboard" data-component="dashboard">...</div>
<div data-route="/settings" data-component="settings">...</div>
</main>
Nested Components and Communication
Alpine components are isolated islands. WildflowerJS supports nested components with props and slots:
<div data-component="user-profile">
<div data-component="avatar-card" data-props="{ name: userName, image: userAvatar }">
<img data-bind-attr="{ src: props.image }" />
<span data-bind="props.name"></span>
</div>
<div data-component="activity-timeline" data-props="{ userId: id }">
...
</div>
</div>
Server-Side Rendering
WildflowerJS includes an SSR manager for rendering components on the server with hydration on the client, something Alpine was never designed for.
Migration Checklist
Follow these steps to migrate an Alpine.js project to WildflowerJS:
- Extract inline
x-dataobjects intowildflower.component()definitions in a separate JS file - Replace
x-*attributes withdata-*equivalents using the attribute mapping table above - Move inline event handlers (
@click="count++") to named methods in the component definition - Convert
Alpine.store()calls towildflower.store()with explicitstateandcomputedblocks - Add lifecycle hooks where needed: replace
x-initwithinit(), adddestroy()for cleanup - Add
data-keyto list containers for efficient keyed reconciliation - Restructure as nested components for larger pages: break monolithic
x-datablocks into composable components - Test each component after migration: verify state, events, computed values, and list rendering all work correctly