Round is a lightweight, DOM-first framework for building SPAs with fine-grained reactivity, and fast, predictable updates powered by signals and bindables
Instead of a Virtual DOM diff, Round updates the UI by subscribing DOM updates directly to reactive primitives signals and bindables. This keeps rendering predictable, small, and fast for interactive apps.
The round-core package is the foundation of RoundJS.
You can think of round-core as:
- A framework-level runtime, not just a state library
- Comparable in scope to React + Router + Signals, but significantly smaller
- Suitable for fast SPAs and simple SSR setups without heavy infrastructure
To use Round JS today, install the core package:
npm install round-coreOr with Bun:
bun add round-coreRound is a No-VDOM framework.
- Direct DOM Manipulation: Components run once. They return real DOM nodes (via
document.createElement). - Fine-Grained Reactivity: Use of
signal,effect, andbindablecreates a dependency graph. - Ergonomic bindings: built-in two-way bindings with
bind:*directives. - Surgical Updates: When a signal changes, only the specific text node, attribute, or property subscribed to that signal is updated. The component function does not re-run.
- A JSX superset:
.roundfiles support extra control-flow syntax that compiles to JavaScript.
This avoids the overhead of Virtual DOM diffing and reconciliation entirely.
A Single Page Application (SPA) loads one HTML page and then updates the UI dynamically as the user navigates and interacts—without full page reloads.
A signal is a small reactive container.
- Reading a signal inside an
effect()tracks a dependency. - Writing to a signal triggers only the subscribed computations.
Round includes a CLI with a project initializer.
# Install the CLI
bun add round-core
# Create a new app
round init myapp
# Navigate to the app directory
cd myapp
# Install dependencies
bun install
# Run the app
bun run devThis scaffolds a minimal Round app with src/app.round and an example src/counter.round.
A .round file is a JSX-based component module (ESM) compiled by the Round toolchain.
You can also use .jsx files, but you will not get the Round JSX superset features
such as extended control flow.
Example src/app.round:
import { Route } from "round-core";
import { Counter } from "./counter.round";
export default function App() {
return (
<div
style={{ display: "flex", flexDirection: "column", alignItems: "center" }}
>
<Route route="/" title="Home">
<Counter />
</Route>
</div>
);
}Create a reactive signal.
- Call with no arguments to read.
- Call with one argument to write.
- Use
.valueto read/write the current value in a non-subscribing way (static access).
import { signal } from "round-core";
export function Counter() {
const count = signal(0);
return (
<div>
<p>Count: {count()}</p>
<button onClick={() => count(count() + 1)}>Increment</button>
<button onClick={() => count(0)}>Reset</button>
</div>
);
}-
signal(0)creates a reactive value initialized to 0 -
Calling
count()reads the current value and subscribes the DOM -
Calling
count(newValue)writes a new value and updates only the subscribed nodes -
The component function runs once — only the text node updates when count changes
const count = signal(5);
// Read without tracking
console.log(count.value); // 5Create a signal that manages asynchronous data fetching.
- It returns a signal that resolves to the data once fetched.
.pending: A reactive signal (boolean) indicating if the fetch is in progress..error: A reactive signal containing any error that occurred during fetching..refetch(): A method to manually trigger a re-fetch.
import { asyncSignal } from 'round-core';
const user = asyncSignal(async () => {
const res = await fetch('/api/user');
return res.json();
});
export function UserProfile() {
return (
<div>
{if(user.pending()){
<div>Loading...</div>
} else if(user.error()){
<div>Error: {user.error().message}</div>
} else {
<div>Welcome, {user().name}</div>
}}
<button onClick={() => user.refetch()}>Reload</button>
</div>
);
}RoundJS utilizes a high-performance reactivity engine designed for efficiency and minimal memory overhead:
- Doubly-Linked List Dependency Tracking: Instead of using heavy
Setobjects, RoundJS uses a linked-list of subscription nodes. This eliminates array spreads and object allocations during signal updates, providing constant-time performance for adding/removing dependencies. - Global Versioning (Clock): Every signal write increments a global version counter. Computed signals (
derive) track the version of their dependencies and only recompute if they are "dirty" (out of date). This ensures true lazyness and avoids redundant calculations. - Automatic Batching: Multiple signal updates within the same execution cycle are batched. Effects and DOM updates only trigger once at the end of the batch, preventing "glitches" and unnecessary re-renders.
Create a computed signal that updates automatically when its dependencies change.
This example demonstrates signal, derive, and the JSX control flow if block.
import { signal, derive } from 'round-core';
export default function Counter() {
const count = signal(0);
// Derive creates a readonly signal that updates when count changes
const doubled = derive(() => count() * 2);
return (
<div>
<h1>Count: {count()} (Doubled: {doubled()})</h1>
{/* Control flow is part of the Round JSX superset */}
{if(count() > 5){
<p style="color: red">Count is high!</p>
}}
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => count(count() + 1)}>Increment</button>
<button onClick={() => count(count() - 1)}>Decrement</button>
</div>
</div>
);
}Round extends JSX inside .round files with a control-flow syntax that compiles to JavaScript. This is preferred over ternary operators for complex logic.
{
if (user.loggedIn) {
<Dashboard />;
} else if (user.loading) {
<div>Loading...</div>;
} else {
<Login />;
}
}Notes:
- Conditions may be normal JS expressions.
- For simple paths like
flags.showCounter(identifier/member paths), Round will auto-unwrap signal-like values (call them) so the condition behaves as expected. - Multiple elements inside a block are automatically wrapped in a Fragment.
{for(item in items()) key=item.id {
<div className="row">{item.name}</div>
}}This compiles to efficient keyed reconciliation using the ForKeyed runtime component.
- Keyed (Recommended): By providing
key=expr, Round maintains the identity of DOM nodes. If the list reorders, Round moves the existing nodes instead of recreating them. This preserves local state (like input focus, cursor position, or CSS animations). - Unkeyed: If no key is provided, Round simply maps over the list. Reordering the list will cause nodes to be reused based on their index, which might lead to state issues in complex lists.
{
switch (status()) {
case "loading":
<Spinner />;
case "error":
<ErrorMessage />;
default:
<DataView />;
}
}Notes:
- The
switchexpression is automatically wrapped in a reactive tracker, ensuring that the view updates surgically when the condition (e.g., a signal) changes. - Each case handles its own rendering without re-running the parent component.
Round supports both static and reactive try/catch blocks inside JSX.
- Static: Just like standard JS, but renders fragments.
- Reactive: By passing one or more signals to
try(...), the block will automatically re-run if any of those signals (or their dependencies) update. This is perfect for handling transient errors in async data.
Single dependency:
{try(user()) {
{/* Note: we access .error because it is a asyncSignal() not a normal signal */}
{if(user().error){
raise(user().error) {/* raise() is a function to throw an error in JSX and is imported from 'round-core' */}
}}
<Profile data={user()} />
} catch(e) {
<div className="error"> Failed to load user: {e.message} </div>
}}Multiple dependencies: You can track multiple signals by listing them using the comma operator. Both signals will be tracked.
{try(signal1(), signal2()) {
<div>
Data 1: {signal1()}
Data 2: {signal2()}
</div>
} catch(e) {
<div>One of the signals threw an error!</div>
}}Run fn whenever the signals it reads change.
import { signal, effect } from "round-core";
const name = signal("Ada");
effect(() => {
console.log("Name changed:", name());
});
name("Grace");Run a function without tracking any signals it reads.
import { signal, untrack, effect } from "round-core";
const count = signal(0);
effect(() => {
console.log("Count is:", count());
untrack(() => {
// This read won't trigger the effect if it changes elsewhere
console.log("Static value:", count());
});
});bindable() creates a signal intended for two-way DOM bindings.
import { bindable } from "round-core";
export function Example() {
const email = bindable("");
return (
<div>
<input bind:value={email} placeholder="Email" />
<div>Typed: {email()}</div>
</div>
);
}Round supports two-way bindings via props:
bind:value={someBindable}for text-like inputs,<textarea>, and<select>.bind:checked={someBindable}for<input type="checkbox">and<input type="radio">.
Round will warn if the value is not signal-like, and will warn if you bind a plain signal() instead of a bindable().
Round supports object-shaped state with ergonomic deep bindings via proxies.
import { bindable } from "round-core";
export function Profile() {
const user = bindable.object({
profile: { bio: "" },
flags: { newsletter: false },
});
return (
<div>
<textarea bind:value={user.profile.bio} />
<label>
<input type="checkbox" bind:checked={user.flags.newsletter} />
Subscribe
</label>
</div>
);
}Create a shared global state store with actions and optional persistence.
import { createStore } from 'round-core';
// 1. Define Store
const store = createStore({
todos: [],
filter: 'all'
}, {
addTodo: (state, text) => ({
...state,
todos: [...state.todos, { text, done: false }]
})
});
// 2. Use in Component
export function TodoList() {
const todos = store.use('todos'); // Returns a bindable signal
return (
<div>
{for(todo in todos()){
<div>{todo.text}</div>
}}
<button onClick={() => store.addTodo('Buy Milk')}>Add</button>
</div>
);
}
// 3. Persistence (Optional)
store.persist('my-app-store', {
debounce: 100, // ms
exclude: ['someSecretKey']
});
// 4. Advanced Methods
store.patch({ filter: 'completed' }); // Update multiple keys at once
const data = store.snapshot({ reactive: false }); // Get static JSON of state
store.set('todos', []); // Direct set(sets all todos to [] it wont save to storage)Attach validation to a signal/bindable.
- Invalid writes do not update the underlying value.
signal.erroris itself a signal (reactive) containing the current error message ornull.options.validateOncan be'input'(default) or'blur'.options.validateInitialcan trigger validation on startup.
import { bindable } from "round-core";
export function EmailField() {
const email = bindable("").validate(
(v) => v.includes("@") || "Invalid email",
{ validateOn: "blur" }
);
return (
<div>
<input bind:value={email} placeholder="name@example.com" />
<div style={() => ({ color: email.error() ? "crimson" : "#666" })}>
{email.error}
</div>
</div>
);
}Round provides hooks to tap into the lifecycle of components. These must be called during the synchronous execution of your component function.
Runs after the component is first created and its elements are added to the DOM. If fn returns a function, it's used as a cleanup (equivalent to onUnmount).
Runs when the component's elements are removed from the DOM.
Runs whenever any signal read during the component's initial render is updated.
Alias for onUnmount.
import { onMount, onUnmount } from "round-core";
export function MyComponent() {
onMount(() => {
console.log("Mounted!");
const timer = setInterval(() => {}, 1000);
return () => clearInterval(timer); // Cleanup
});
onUnmount(() => console.log("Goodbye!"));
return <div>Hello</div>;
}Round includes router primitives intended for SPA navigation. All route paths must start with a forward slash /.
import { Route, Link } from "round-core";
export default function App() {
return (
<div>
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
</nav>
<Route route="/" title="Home" exact>
<div>Welcome Home</div>
</Route>
<Route route="/about" title="About">
<div>About Us Content</div>
</Route>
</div>
);
}Routes can be nested to create hierarchical layouts. Child routes automatically inherit and combine paths with their parents.
- Prefix Matching: By default, routes use prefix matching (except for the root
/). This allows a parent route to stay rendered as a "shell" or layout while its children are visited. - Exact Matching: Use the
exactprop to ensure a route only renders when the path matches precisely (default for root/).
<Route route="/dashboard" title="Dashboard">
<h1>Dashboard Shell</h1>
{/* This route matches /dashboard/profile */}
<Route route="/dashboard/profile">
<h2>User Profile</h2>
</Route>
{/* This route matches /dashboard/settings */}
<Route route="/dashboard/settings">
<h2>Settings</h2>
</Route>
</Route>Round allows you to persist the state and DOM nodes of a route even when you navigate away. This is perfect for maintaining the state of a complex list or preserving the state of a multi-step form.
When memo is set to true, the route's DOM nodes are hidden using display: none instead of being removed. This ensures that:
- Signals and local state are preserved.
- CSS Transitions and Animations don't reset.
- Form inputs maintain their value and focus.
<Route route="/explore" memo={true}>
<ExploreFeed />
</Route>Tip
This works even if the Route component itself is unmounted and remounted (e.g., within an if block), as Round maintains a global persistence cache for memoized routes.
Round supports Suspense for promise-based rendering and lazy() for code splitting.
import { Suspense, lazy } from "round-core";
const LazyWidget = lazy(() => import("./Widget"));
<Suspense fallback={<div>Loading...</div>}>
<LazyWidget />
</Suspense>;Round includes an optional companion package for markdown rendering with syntax highlighting and rich code blocks.
Install it alongside round-core:
npm install @round-core/markdownor with Bun:
bun add @round-core/markdownThen use it in a .round file or JSX component:
import { createElement } from "round-core";
import {
Markdown,
defaultMarkdownStyles as markdown,
} from "@round-core/markdown";
import "@round-core/markdown/styles.css";
export default function App() {
const content = `
# Markdown with code blocks
You can render fenced code blocks with syntax highlighting:
\`\`\`javascript
const value = signal(0);
\`\`\`
`;
return (
<div>
<Markdown content={content} />
</div>
);
}You can also theme the markdown surface (background/text) and accents via
options.theme:
<Markdown
content={content}
options={{
theme: {
// Dark card-like surface
markdownBackground: '#020617',
markdownText: '#e5e7eb',
primaryColor: '#e5e7eb',
secondaryColor: '#38bdf8',
},
}}
/>
<Markdown
content={content}
options={{
theme: {
// Light mode
markdownBackground: '#ffffff',
markdownText: '#0f172a',
primaryColor: '#0f172a',
secondaryColor: '#2563eb',
},
}}
/>Round JS favors individual error control and standard browser debugging:
- Explict
try/catch: Use the JSXtry/catchsyntax to handle local component failures gracefully. - Console-First Reporting: Unhandled errors in component rendering or reactive effects are logged to the browser console with descriptive metadata (component name, render phase) and then allowed to propagate.
- No Intrusive Overlays: Round has removed conflicting global error boundaries to ensure that your local handling logic always takes precedence and the developer experience remains clean.
Example of a descriptive console log:
[round] Error in phase "component.render" of component <UserProfile />: TypeError: Cannot read property 'avatar' of undefined
The CLI is intended for day-to-day development:
round devround buildround previewround init <name>
Run round -h to see available commands.
Round is under active development and the API is still stabilizing. The README is currently the primary documentation; a dedicated documentation site will be built later using Round itself.
MIT
