Flynt.js is a lightweight, zero-build reactivity library built specifically for Multi-Page Applications (MPAs) like Magento/HyvΓ€, Blade, or WordPress.
It organizes UI logic into clean, decoupled Presenters without the bloat, virtual DOM, or build step of full SPA frameworks.
- Built for MPAs: Add reactivity to server-rendered HTML effortlessly.
- Presenter Pattern: Decouple reactive state logic cleanly from your DOM.
- Zero Build Step & 2.2 kB: Drop in a single tiny CDN script tag and start coding.
- Zero Lifecycle Overhead: No unmount hooks or cleanup complexity β browser garbage collection handles it naturally on page navigation.
π» Quick Start
1. Include script
<script src="https://cdn.jsdelivr.net/gh/marsbos/flynt.js@v1.0.1/flynt.min.js"></script>
2. Bind HTML (DOM)
<div> <button data-fx="countPresenter.incrBtn">Increment</button> <button data-fx="countPresenter.decrBtn">Decrement</button> <span data-fx="countPresenter.counter"></span> </div>
3. Define Presenter (Logic)
<script> window.countPresenter = fx.presenter(({ createState, render }) => { const state = createState({ count: 0, // Derived state via standard JS getters get double() { return this.count * 2; } }); return { incrBtn(el) { el.onclick = () => state.count++; }, decrBtn(el) { el.onclick = () => state.count--; }, counter(el) { render(() => { el.innerText = `count=${state.count}, double=${state.double}`; }); }, }; }); </script>
π§ͺ Examples
1. Codepen examples: Flynt.js
2. Browse the examples folder in this repo
More demo's coming soon...
π API Reference
DOM Attribute
data-fx="PresenterName.methodName"
Binds a DOM element to a specific method inside a Presenter. Flynt.js automatically initializes the binding when the element is parsed.
Core Methods
fx.presenter(setupFn) Creates a new Presenter context.
- Parameters:
function- setupFn (Function): A setup function receiving helpers { createState, render, map, debounce, request }. Must return an object with component/presenter methods.
createState(initialState) Creates a reactive state object inside a Presenter context.
Supports standard JavaScript getters for derived state.
- Parameters:
object- initialState (Object)
Returns: JS object that triggers re-renders when mutated.
render(callback)
Registers a reactive effect that automatically re-runs whenever accessed state properties change.
- Parameters:
function- callback (Function) β The DOM update logic to run.
map(containerElement) Higher-Order Factory for list reconciliation. Returns an itemsUpdater function tied to containerElement.
- Parameters:
Element- element: A dom element acting as the container for the reconciliation operation
const mapItems = map(containerElement); mapItems([{ key: 1, html: "<li>Item 1</li>" }]);
-
$O(1)$ Lookup Cache: Retains physical DOM references across renders. - In-Place Updates: Only replaces elements whose outerHTML has changed.
- Order Preservation: Uses insertBefore() to reorder nodes without losing focus or event listeners.
debounce(fn, delay) Delays function execution until delay milliseconds have passed since the last invocation.
- Parameters:
function, number- fn (Function), delay (Number)
request() Creates an isolated, abortable request runner. Multiple calls on the same runner will automatically cancel the previous in-flight request to prevent race conditions.
-
Returns:
request(options)(Function)- Parameters:
options(Object)url(String) β Target URLmethod(String) β HTTP method (default:'GET')body(Object|String) β Request body (Objects are automatically stringified)loading(Function) β Receives boolean (true) on start and (false) on completion/failure.response(Function) β Custom response parser (default:res => res.json()). Useful forres.text(),res.blob(), etc.success(Function) β Callback on success(data) => {}error(Function) β Callback on HTTP, GraphQL, or network error(err) => {}
- Parameters:
π― Why Flynt.js?
Modern frontend tooling often forces a tough choice: write verbose Vanilla JS DOM manipulations, bloat your markup with inline logic (x-data="{ ... }"), or drag in hundreds of kilobytes of SPA framework overhead.
Flynt.js takes a different path: Keep HTML clean, keep code structured, keep bundles microscopic.
| Feature | Flynt.js | Alpine.js | React / Vue |
|---|---|---|---|
| Bundle Size | ~2.2 KB minified | ~15 KB | ~40β130 KB |
| HTML Markup | 100% Clean (data-fx="...") |
Inline (x-data, x-init) |
JSX / Templates |
| DOM Reconciliation | Keyed Native Diffing | Full node morphing | Virtual DOM |
| Build Step | None required | None required | Required |
| Architecture | Presenter Closures | Inline Directives | Component Trees |
π Key Features
- π¦ Microscopic Footprint: Under 2.2KB gzipped. Zero dependencies.
- π§Ή Clean HTML: Zero JavaScript logic inside your markup β just clean, declarative
data-fxattributes. - π Fine-Grained Reactivity: Automatic dependency tracking using native JS getters and setters.
- β‘ Microtask Batching: Multiple state mutations are batched into a single render frame for maximum performance.
- π§© Keyed List Reconciliation: Built-in
map()helper for$O(1)$ lookup and in-place DOM updates for dynamic arrays. - π§ Zero Magic: ~165 lines of clean JS that any developer can inspect and master in 10 minutes.
βοΈ The Mechanics
How state works
Flynt.js uses Fine-Grained Microtask Reactivity powered by native JavaScript Object.defineProperty getters and setters.
State Change ββ> Dependency Tracking ββ> Microtask Batching ββ> DOM Render
- Getter-Based Dependency Tracking: When a property inside your
createStateobject is read inside arender()block, Flynt.js automatically registers that property as a dependency. - Microtask Batching: Multiple state mutations in the same execution cycle (e.g., updating
state.countandstate.nameright after each other) are batched together viaqueueMicrotask(). Your DOM updates only once per cycle. - No Virtual DOM: Flynt.js mutates the actual DOM directly where neededβgiving you raw browser performance with zero abstraction overhead.
Function Updates
You can update state directly or pass a function to derive the new value based on the previous state:
// Direct update state.count = 5; // Function update (receives current value) state.count = (prev) => prev + 1;
Array Updates
β οΈ Note: Array properties in your state must be updated immutably! Mutating an array directly (e.g., viapush,pop, orsplice) will not trigger a reactivity re-render. Always assign a fresh array instance:
// β Don't mutate directly state.items.push(newItem); // β Update immutably with spread syntax state.items = [...state.items, newItem]; // β Or filter/map immutably state.items = state.items.filter(item => item.id !== targetId);
How DOM Binding Works (data-fx)
Flynt.js bridges your JavaScript presenters to the HTML DOM using the data-fx attribute. When the page loads, Flynt automatically scans the document, resolves the method path on window, and passes the matched DOM element directly to your presenter function.
<!-- HTML --> <div data-fx="productPresenter.productListing"></div> // JavaScript window.productPresenter = fx.presenter(({ createState, render }) => { return { // Flynt executes this automatically and passes the <div> element as 'el' productListing(el) { render(() => { el.textContent = "Bound and reactive!"; }); } }; });
-
Zero Config Scanning: No manual document.querySelector or initialization calls required.
-
Nested Path Resolution: Dot notation is fully supported (e.g., data-fx="shop.catalog.productPresenter.item").
-
Explicit Errors: If a specified path or method doesn't exist on window, Flynt immediately throws a clear console error to speed up debugging.
β‘ Real-World Example: Master-Detail List with Pagination
Flynt.js effortlessly handles complex asynchronous workflows, nested reactivity, state-driven UI disabled modifiers, and keyed DOM updates.
<main> <ul data-fx="productPresenter.productListing"></ul> <button data-fx="productPresenter.loadMoreBtn" class="btn disabled:opacity-50 disabled:cursor-not-allowed" > Load More </button> </main> <script> window.productPresenter = fx.presenter(({ createState, render, map }) => { const state = createState({ loading: false, products: [], page: { skip: 0, limit: 10 }, selectedProduct: undefined, }); const getProducts = async (skip = 0) => { state.loading = true; try { const res = await fetch( `https://dummyjson.com/products?limit=10&skip=${skip}`, ); const data = await res.json(); state.products = [...state.products, ...(data?.products || [])]; } finally { state.loading = false; } }; // Reactive Effect: Fetch data whenever page.skip changes render(async () => { await getProducts(state.page.skip); }); return { loadMoreBtn(el) { el.onclick = () => (state.page.skip += 10); render(() => { el.disabled = state.loading; }); }, productListing(el) { const mapProducts = map(el); el.onclick = (evt) => { const id = evt.target.closest("li")?.dataset.productId; if (id) state.selectedProduct = Number(id); }; render(() => { const items = state.products.map((prod) => ({ key: prod.id, html: `<li class="${prod.id === state.selectedProduct ? "active" : ""}" data-product-id="${prod.id}">${prod.title}</li>`, })); // Fast, in-place keyed DOM diffing mapProducts(items); }); }, }; }); </script>
βοΈ License
MIT Β© Marcel Bos
Distributed under the MIT License. Free for commercial and non-commercial use.