A game-engine inspired reactive UI framework using FrameSync — a novel rendering technique that decouples state mutations from rendering, synchronizing DOM updates with your display's refresh rate.
FrameSync is a rendering paradigm inspired by game engines where state mutations happen instantly, but rendering is synchronized to the display's refresh rate via requestAnimationFrame.
Traditional frameworks couple state changes to rendering. VaporJS separates them completely.
// Traditional frameworks (React, Vue, Svelte):
setState(1); // Triggers render pipeline
setState(2); // Triggers render pipeline again
setState(3); // Triggers render pipeline again
// Result: Multiple render cycles, even within a single frame
// VaporJS with FrameSync:
setCount(1); // Updates signal instantly (synchronous)
setCount(2); // Updates signal instantly (synchronous)
setCount(3); // Updates signal instantly (synchronous)
// Result: One render on next frame with final value (count = 3)Why this matters:
- Your display refreshes at 60/120/144Hz
- You can't see updates faster than your refresh rate
- Why render multiple times when the display only shows once per frame?
Signal mutations are synchronous. No batching delays, no microtasks, no promises.
const [count, setCount] = createSignal(0);
setCount(5); // Updates immediately
console.log(count()); // 5 - instantly readableAsynchronous DOM updates happen once per display frame via requestAnimationFrame, perfectly aligned with your monitor's refresh rate.
Like Solid.js, VaporJS tracks reactive dependencies and updates only affected DOM nodes. No virtual DOM, no diffing.
const [count, setCount] = createSignal(0);
// Only the text node updates when count changes
<div>
<p>Count: {count}</p> {/* Reactive */}
<p>Static text</p> {/* Never re-evaluated */}
</div>VaporJS uses JSX for ergonomics but extracts reactive "holes" at runtime, binding them directly to DOM nodes without a compiler (compiler optimization possible in the future).
When your tab goes to the background, requestAnimationFrame pauses automatically. Saving CPU and battery.
No matter how many signal updates happen in a frame, only one render occurs. Perfectly synced to display refresh.
Clone the repo. Navigate to src/playground.tsx.
import { createSignal } from "./signal";
import { jsx } from "./web";
import { connectHoles } from "./web/flusher";
const [count, setCount] = createSignal(0);
const App = () => (
<div>
<p>Count: {count()}</p>
<button onClick={() => setCount(prev => prev + 1)}>
Increment
</button>
</div>
);
const tree = App();
const { syncFrame } = connectHoles(tree.holes);
// Start the FrameSync render loop
syncFrame();
// Mount to DOM
document.querySelector("#app")!.append(tree.fragment);That's it! VaporJS handles the rest.
Signals are VaporJS's reactive primitives. Similar to Solid.js signals.
Warning
The current signal implementation will be replaced with a more efficient system to provide finer-grained control.
import { createSignal } from "./signal";
const [count, setCount] = createSignal(0);
// Read value
console.log(count.get()); // 0
// Update value
setCount(5);
console.log(count.get()); // 5
// Functional updates
setCount(prev => prev + 1);
console.log(count.get()); // 6Key characteristics:
- ✅ Synchronous state reads and writes
- ✅ Automatic dependency tracking
- ✅ Batched re-rendering into a single frame
VaporJS uses JSX for templating but processes it similar to SolidJS:
import { jsx } from "./web";
const [name, setName] = createSignal("World");
const Greeting = () => (
<div>
<h1>Hello, {name}!</h1> {/* Reactive hole */}
<p>Welcome to VaporJS</p> {/* Static */}
</div>
);What happens:
- JSX is transformed into
jsx()function calls - VaporJS extracts reactive holes (e.g.,
{name}) - These holes are bound directly to DOM text nodes
- When
namechanges, only that text node updates
No virtual DOM. No diffing. Just direct bindings.
The magic happens in syncFrame():
What it does:
// Check which signals changed (dirty flag)
const { syncFrame } = connectHoles(tree.holes);
// Starts the render loop
syncFrame();
// Update only affected DOM nodes
// Repeat next frameKey insight: State updates flip a "dirty flag" on affected bindings. The render loop flushes these dirty nodes once per frame.
User clicks button
↓
setCount(prev => prev + 1) ← Signal updates instantly
↓
Marks dependent nodes as "dirty"
↓
["syncFrame" loop wait for next frame...]
↓
Flushes dirty nodes → Updates DOM
↓
Display refreshes → User sees change
Timeline:
t=0ms: Button click, signal updates, dirty flag sett=16ms: Display refreshes,syncFrame()flushes dirty nodes- Result: One render per frame, perfectly synced
┌─────────────────────────────────┐
│ Signal Layer (State) │
│ - Instant, synchronous updates │
│ - Dependency tracking │
│ - Dirty flag management │
└──────────────┬──────────────────┘
│
│ notifies
↓
┌─────────────────────────────────┐
│ Reactive Holes (Bindings) │
│ - Maps signals → DOM nodes │
│ - Tracks "dirty" state │
└──────────────┬──────────────────┘
│
│ reads
↓
┌──────────────────────────────────┐
│ FrameSync Loop (Rendering) │
│ - Runs via requestAnimationFrame│
│ - Flushes dirty nodes once/frame│
│ - Updates DOM directly │
└──────────────────────────────────┘
| Framework | State Updates | Rendering Trigger | Renders/Frame | Virtual DOM |
|---|---|---|---|---|
| React | Batched (microtask) | State change | Multiple (batched) | Yes |
| Vue 3 | Batched (microtask) | State change | Multiple (batched) | Yes |
| Svelte | Batched (microtask) | State change | Multiple (batched) | No (compiled) |
| Solid.js | Synchronous | State change | Multiple | No (fine-grained) |
| VaporJS | Synchronous | rAF loop | Exactly one | No (fine-grained) |
The key difference: VaporJS is the only framework that decouples rendering from state updates using FrameSync.
const [dataPoints, setDataPoints] = createSignal([]);
// Simulate high-frequency updates (e.g., stock prices)
setInterval(() => {
setDataPoints(prev => [
...prev,
{ time: Date.now(), value: Math.random() * 100 }
].slice(-50)); // Keep last 50 points
}, 10); // Updating every 10ms!
const Chart = () => (
<svg width="500" height="200">
{dataPoints().map((point, i) => (
<circle
cx={i * 10}
cy={200 - point.value}
r="2"
fill="blue"
/>
))}
</svg>
);
// Despite 100 updates/second, only renders at 60fps (or your display's refresh rate)const [player, setPlayer] = createSignal({ x: 0, y: 0, vx: 0, vy: 0 });
// Physics update (runs every frame)
const update = (deltaTime) => {
setPlayer(prev => ({
x: prev.x + prev.vx * deltaTime,
y: prev.y + prev.vy * deltaTime,
vx: prev.vx * 0.98, // Friction
vy: prev.vy * 0.98
}));
};
// Input handling
document.addEventListener("keydown", (e) => {
setPlayer(prev => {
if (e.key === "ArrowRight") return { ...prev, vx: 5 };
if (e.key === "ArrowLeft") return { ...prev, vx: -5 };
if (e.key === "ArrowUp") return { ...prev, vy: -5 };
if (e.key === "ArrowDown") return { ...prev, vy: 5 };
return prev;
});
});
const Game = () => (
<div
style={{
position: "absolute",
left: `${player().x}px`,
top: `${player().y}px`,
width: "50px",
height: "50px",
background: "blue"
}}
/>
);
// Custom update loop
let lastTime = performance.now();
function gameLoop() {
const now = performance.now();
const deltaTime = (now - lastTime) / 1000; // Convert to seconds
lastTime = now;
update(deltaTime);
requestAnimationFrame(gameLoop);
}
gameLoop();Strengths:
- ⚡ Extremely fast for high-frequency updates (no wasted renders)
- 🎯 Fine-grained reactivity (updates only changed nodes)
- 🔋 Battery-efficient (background tab optimization)
- 📦 Small bundle size (no virtual DOM, no compiler required)
Trade-offs:
- Initial render may be slightly slower than compiled frameworks (Svelte)
- No compiler optimizations (- yet, possible future direction)
- Manual JSX binding at runtime (could be optimized)
- Effects API (
createEffect,createMemo) - Lifecycle hooks
- More examples and templates
- Performance benchmarks
- DevTools extension
- Compiler approach: Optimize reactive bindings at compile-time (like Solid.js)
- ECS integration: Bring Entity-Component-System patterns to DOM
- WebGL/Canvas renderer: Extend FrameSync beyond DOM
- Time-slicing: Optional work splitting for heavy computations
- Concurrent rendering: Pause/resume capability for large updates
VaporJS is built on these principles:
-
State and rendering are separate concerns Like game engines, state mutations should be instant and rendering should sync to display hardware.
-
Respect the display refresh rate Rendering faster than 60/120/144Hz is wasted work. Sync to the frame.
-
Fine-grained reactivity Update only what changed. No virtual DOM, no diffing.
-
Simple mental model State is just data. Rendering is just a loop. No magic.
-
Performance by design Don't optimize bad architectures. Start with a good one.
VaporJS draws inspiration from:
- Game engines (Unity, Godot, Bevy) - render loops and state separation
- Solid.js - fine-grained reactivity without virtual DOM
- Three.js - independent render loops
- ECS architecture - data-oriented design patterns
- The observation that web frameworks waste renders between display refreshes
Solid.js is amazing, but it still couples state changes to immediate DOM updates. VaporJS takes it further by decoupling rendering entirely via FrameSync.
Yes! State updates are instant, so your logic responds immediately. The visual update (DOM) happens on the next frame (< 16ms at 60fps), which is imperceptible to users.
VaporJS is currently client-focused (uses requestAnimationFrame). SSR/SSG support is on the roadmap, likely following a hydration approach similar to other frameworks.
VaporJS is experimental. It explores a novel rendering paradigm. Use at your own risk, but we'd love feedback!
Like vapor, it's light and fast. And it's inspired by vaporwave aesthetics. Smooth, synchronized, and performant. 🌊✨
Possibly! Right now VaporJS binds reactivity at runtime (like early Solid.js). A compiler could optimize this further, eliminating runtime overhead. It's on the roadmap.
We welcome contributions! VaporJS is an exploration of a new paradigm.
Areas for help:
- 🧪 Benchmarks and performance testing
- 📚 Documentation and examples
- 🛠️ Developer tooling (DevTools, VSCode extensions)
- 🎨 UI component library
- 🔬 Compiler exploration
MIT License - see LICENSE for details.
VaporJS: Instant state. Frame-synced rendering. A new paradigm. 🎮✨