GUIDE
Framework runtime
The components work in any framework, and they also work with none. For apps that want a small runtime of their own, Dojo NG has three packages: a renderer, a pub/sub, and a WebSocket client. Each one is plain ESM with no Lit inside, and you can use any one of them without the others.
| Package | What it gives you |
|---|---|
@dojo-ng/framework | A virtual DOM renderer. You describe the page with v(), and it updates the real DOM when your data changes. |
@dojo-ng/pubsub | Publish and subscribe with namespaced topics. It keeps the last value of each topic for late subscribers. |
@dojo-ng/websocket | A WebSocket client that reconnects, queues messages while offline, and matches each response to its request. |
Install from npm with npm install @dojo-ng/framework @dojo-ng/pubsub @dojo-ng/websocket, or load them from esm.sh with an import map. The WebSocket client imports the pub/sub, so mark it external to share one copy:
<script type="importmap">
{
"imports": {
"@dojo-ng/framework": "https://esm.sh/@dojo-ng/framework",
"@dojo-ng/pubsub": "https://esm.sh/@dojo-ng/pubsub",
"@dojo-ng/websocket": "https://esm.sh/@dojo-ng/websocket?external=@dojo-ng/pubsub"
}
}
</script>
The renderer
Write a function that returns the view, and give it to renderer(). When your data changes, call invalidate(). The renderer runs your function again, compares the result with the last one, and changes only what is different. There is no component class and no state system: your data is ordinary variables.
import { renderer, v } from "@dojo-ng/framework";
import "@dojo-ng/button";
let count = 0;
function view() {
return v("div", {}, [
v("p", {}, [`Clicked ${count} times`]),
v("dj-button", { onclick: () => { count++; app.invalidate(); } }, ["Add one"]),
]);
}
const app = renderer(view);
app.mount({ domNode: document.getElementById("app") });
v(tag, properties, children) describes one element. Children are other v() calls or strings. null, undefined, and false render nothing, so open && v(...) works.
Many invalidate() calls in the same task cause only one render, on the next microtask. Call flush() to render at once, or mount with sync: true to render on every call. unmount() removes the tree.
Properties, attributes, and events
The renderer decides how to set each property:
| Property | What the renderer does |
|---|---|
on + name, with a function | Adds an event listener for the rest of the name. onclick listens for click, and "ondj-close" listens for dj-close. |
key | Identifies an item in a list. With keys, the renderer moves elements instead of rebuilding them. |
classes | A string or an array of strings. Empty values are skipped. |
styles | An object of inline styles, such as { color: "red" }. |
| A string value | On a custom element that has a property with that name, sets the property. Otherwise sets the attribute, so aria-* and data-* work. |
| Any other value | Sets the property. Arrays, objects, and booleans reach the element as they are, with no conversion to text. |
This is why the components need no wrapper here. A list of options or a columns array goes straight to the element's property. Events from the components bubble like any DOM event, so a listener on a parent element also works.
This list of chips uses keys, a boolean property, and a custom event. Close a chip and the others stay as they are:
import { renderer, v } from "@dojo-ng/framework";
import "@dojo-ng/chip";
let tags = ["design", "docs", "release"];
function view() {
return v("div", { classes: "tags" }, tags.map((tag) =>
v("dj-chip", {
key: tag, // keeps each chip with its tag when the list changes
closeable: true, // not a string, so it is set as a property
"ondj-close": () => { // a custom event, written like any other on* prop
tags = tags.filter((t) => t !== tag);
app.invalidate();
},
}, [tag])
));
}
const app = renderer(view);
app.mount({ domNode: document.getElementById("tags") });
For JSX, configure your compiler to use tsx as the factory. To put an element that you created yourself into the tree, wrap it with dom({ node }).
Pub/sub
createPubSub() returns a message bus. Most messages in an app are really shared state, such as "the cart now has 3 items". So the bus keeps the last value of each topic, and a new subscriber receives it at once. Pass { replay: false } when you only want new messages.
Topics nest on :. A publish to cart:total also reaches subscribers of cart, most specific first. The handler receives the payload and the topic that was published, so a namespace subscriber can tell the topics apart. Pass { separator: "/" } to createPubSub to use a different separator.
import { createPubSub } from "@dojo-ng/pubsub";
const bus = createPubSub();
bus.publish("cart:count", 3);
// Late subscribers get the last value at once (replay is on by default).
bus.subscribe("cart:count", (count) => console.log("count", count)); // count 3
// A namespace subscriber hears every topic under it, with the real topic.
bus.subscribe("cart", (payload, topic) => console.log(topic, payload), { replay: false });
bus.publish("cart:total", 42); // logs "cart:total 42"
bus.getLast("cart"); // 42, the newest value at or under "cart"
subscribe returns a function that removes the subscription. Every publish calls the handlers, even when the value is the same as before.
subscribeAsync gives the same messages as an async iterable for for await. It starts listening when you call it, and it does not replay the last value. Pass a signal to stop from outside the loop. If the loop is slower than the messages, up to 256 messages wait, and after that the oldest are dropped. Change the limit with bufferSize.
const stop = new AbortController();
for await (const total of bus.subscribeAsync("cart:total", { signal: stop.signal })) {
console.log("total is now", total);
if (total > 100) break; // break also ends the subscription
}
WebSocket client
WSDispatcher handles the parts of a WebSocket connection that every app has to write again: it queues messages while the connection is down, reconnects with growing delays, and matches each response to its request. The app sees only two things: call() for requests, and the pub/sub bus for events in both directions.
import { createPubSub } from "@dojo-ng/pubsub";
import { WSDispatcher } from "@dojo-ng/websocket";
const bus = createPubSub();
const server = new WSDispatcher({
url: "wss://example.com/ws",
pubsub: bus, // share the app's bus; without this you get a private one
onConnect: () => [{ action: "event", id: 0, data: { event: "hello", params: { user: 7 } } }],
});
server.open();
// Request and response. Sent now if the socket is open, otherwise queued.
const user = await server.call({ method: "getUser", args: [7] });
// Events from the server arrive on the bus under their own name.
bus.subscribe("chat:message", (msg) => console.log(msg), { replay: false });
// Publishing to the outbound topic sends an event to the server.
bus.publish("notify:event", { event: "chat:message", params: { text: "Hi" } });
onConnectreturns messages to send first on every connect and reconnect, before the queue. Use it to tell the server who the user is.- The queue removes a message only after it is sent, so a failed send does not lose it.
- After a lost connection, the client waits longer before each new try. A successful connect resets the delay. After
close(), it does not reconnect. - When the server sends
pingmessages, the client expects one at least every 40 seconds. If none arrives, it closes and reconnects. A server that never sends a ping turns this check off. call()has no timeout. If the server never answers, the promise never settles. UsePromise.racewith a timer if you need a limit.simulate("disconnect")andsimulate("connect")let you test your reconnect handling without a real outage.
Every message on the wire is one JSON object with action, id, and data. A call response sends back the same id, and its data becomes the value of the promise. An event carries the topic name and the payload. The full rules are in the JSON Schema.
{ "action": "call", "id": 12, "data": { "method": "getUser", "args": [7] } }
{ "action": "event", "id": 0, "data": { "event": "chat:message", "params": { "text": "Hi" } } }
{ "action": "ping", "id": 0, "data": null }
If you only want a thinner wrapper, the package also exports WSocket. It smooths over differences between WebSocket implementations, and its send() tells you whether the message was sent, buffered, or refused.