We just released Hedystia 2.1, and this time we're going full frontend with @hedystia/view — a fine-grained reactive UI engine that builds modern interfaces without a Virtual DOM.
If you've used Hedystia for the backend, you already know our philosophy: type safety, developer experience, and zero compromise on performance. Now we're bringing that same energy to the frontend.
TL;DR:
@hedystia/viewis a reactive UI framework where components run once, and reactivity updates only the exact DOM nodes that changed. No VDOM diffing. No reconciliation. Just surgical updates.Install:
bun add @hedystia/view
Docs: https://docs.hedystia.com/view/start
Why Build Another UI Framework?
We looked at the existing ecosystem and noticed something: most frameworks either use a Virtual DOM (React, Vue) or require a complete mental model shift (Solid, Svelte). We wanted something different:
- No Virtual DOM — Create real DOM nodes directly
- Components run once — No re-rendering, ever
- Explicit reactivity — You control exactly what's reactive and what isn't
- Type-safe end-to-end — Same type safety from backend to frontend
- Familiar JSX — No new template syntax to learn
The Core Idea: Signals + JSX
At the heart of View is a signals system. Think of it as atomic reactive cells:
import { sig, val, set, update } from "@hedystia/view";
const count = sig(0);
console.log(val(count)); // 0
set(count, 1);
console.log(val(count)); // 1
The magic happens in JSX with one simple pattern:
function Counter() {
// This function runs ONCE when the component mounts.
// Only the text node created by the reactive accessor updates.
return (
<div>
{/* Static — read once during mount */}
<h1>Counter</h1>
{/* Reactive — creates a fine-grained effect */}
<p>Count: {() => val(count)}</p>
<button onClick={() => update(count, (c) => c + 1)}>
Increment
</button>
</div>
);
}
The rule: {val(signal)} is static. {() => val(signal)} is reactive. That's it. Apply this pattern to props, children, styles, class names — everything.
No Virtual DOM, No Overhead
When count changes, View doesn't diff a virtual tree or re-render the component. It updates exactly one text node. That's it.
This is possible because:
- Component functions execute once during mount
- Reactive accessors create effects that track their specific DOM nodes
- Signal dependencies trigger targeted updates — no reconciliation needed
The result? UIs that scale without the performance cliff of large re-renders.
Full Reactivity Toolkit
Batch & Untrack
Control when reactivity fires:
import { batch, untrack } from "@hedystia/view";
// Multiple updates, ONE reactive cycle
batch(() => {
set(user.name, "Alice");
set(user.email, "alice@example.com");
set(user.age, 30);
});
// Read without creating a dependency
const snapshot = untrack(() => val(signal));
Memo — Lazy Derived Values
const items = sig([1, 2, 3]);
const doubled = memo(() => val(items).map((x) => x * 2));
const sum = memo(() => val(doubled).reduce((a, b) => a + b, 0));
// Computed lazily — only when read
console.log(val(sum)); // 12
Effects with Cleanup
import { on, once } from "@hedystia/view";
const dispose = on(
() => val(userId),
(id, prevId) => {
console.log(`Switched from ${prevId} to ${id}`);
// Return cleanup function
return () => cleanupUser(id);
}
);
// One-time effect
once(() => val(initialData), (data) => {
console.log("Loaded:", data);
});
Store — Nested Reactive State
For complex state, use the proxy-based store:
import { store, val, set, patch, snap } from "@hedystia/view";
const user = store({
name: "Alice",
address: { city: "Wonderland" },
});
val(user.name); // "Alice"
val(user.address.city); // "Wonderland"
set(user.address.city, "NYC");
// Deep partial update
patch(user.address, { city: "Los Angeles" });
// Snapshot to plain object
const copy = snap(user);
Flow Control Components
Conditional Rendering
<Show when={() => val(isLoggedIn)} fallback={<Login />}>
<Dashboard />
</Show>
Multi-way Conditionals
<Switch>
<Match when={() => val(status) === "loading"}><Spinner /></Match>
<Match when={() => val(status) === "error"}><Error /></Match>
<Match when={() => val(status) === "success"}><Data /></Match>
</Switch>
Keyed Lists
<For each={() => val(users)} key={(u) => u.id}>
{(user) => <UserCard user={user} />}
</For>
Index-Based Lists
<Index each={() => val(items)}>
{(item, index) => <div>#{index}: {() => val(item)}</div>}
</Index>
Portal
<Portal>
<Modal>Content outside hierarchy</Modal>
</Portal>
Data Fetching Built In
Load — Reactive Queries
const userId = sig(1);
const user = load(
() => `user-${val(userId)}`, // Key triggers refetch
async () => {
const res = await fetch(`/api/users/${val(userId)}`);
return res.json();
}
);
// user.data, user.loading, user.error, user.ready
Auto-refetches when the key changes. Auto-aborts previous in-flight requests.
Action — Reactive Mutations
const createPost = action(async (data) => {
return fetch("/api/posts", { method: "POST", body: JSON.stringify(data) })
.then((r) => r.json());
});
createPost.run({ title: "Hello" });
// createPost.loading, createPost.error, createPost.data
Context — Dependency Injection
import { ctx, use, Context } from "@hedystia/view";
const themeCtx = ctx<"light" | "dark">("light");
function App() {
return (
<Context.Provider value={themeCtx} value={() => "dark"}>
<Header />
</Context.Provider>
);
}
function Header() {
const theme = use(themeCtx); // "dark"
return <header class={val(theme)}>Themed</header>;
}
Lifecycle Hooks
function MyComponent() {
onMount(() => {
console.log("Mounted");
return () => console.log("Cleanup");
});
onReady(() => {
// After first render — perfect for focus/measurements
inputRef.focus();
});
onCleanup(() => {
// Alternative cleanup registration
});
return <input ref={(el) => (inputRef = el)} />;
}
Rendering & SSR
import { mount, renderToString } from "@hedystia/view";
// Mount to browser
const app = mount(<App />, document.getElementById("app")!);
app.dispose(); // Unmount
// Server-side rendering
const html = renderToString(<App />);
The Performance Difference
Because View doesn't use a Virtual DOM:
- No diffing overhead — Updates go straight to the DOM
- No reconciliation — No tree traversal to find what changed
- Predictable performance — O(1) updates regardless of tree size
- Lower memory — No virtual tree to maintain
For small apps the difference is negligible. For complex dashboards, data tables, and real-time UIs, it's noticeable.
What Else Is New?
Hedystia 2.1 also includes all the features from our recent releases:
-
2.0 —
@hedystia/db: Type-safe ORM with MySQL, SQLite, and File support, smart caching, and migrations - 1.10 — Route testing, conditional routes, WebSocket support, and framework-agnostic type generation
Getting Started
# Install
bun add @hedystia/view
# Or with the full framework
bun add hedystia @hedystia/view
Check out the interactive docs at https://docs.hedystia.com/view/start
What's Next?
We're working on:
- DevTools for signal debugging
- Transition/animation system
- More flow control components
- Performance profiling utilities
We'd love your feedback! Try it out, break things, and let us know what you think on Discord or GitHub.
Hedystia is an open-source TypeScript backend framework now with a full-stack reactive UI engine. Type-safe from database to DOM.
Top comments (0)