dojo/topicDecouple every module in your app — publish events, subscribe anywhere, never hold direct references between widgets
topic.publish() — Firing Eventstopic.subscribe() — Listening & CleanupIn a tightly coupled Dojo app, widgets hold direct references to each other. When widget A needs to notify widget B, it calls a method on B directly. This creates a web of dependencies that makes testing, reuse, and refactoring painful.
// TIGHTLY COUPLED — EmployeeList holds a direct ref to EmployeeDetail
var EmployeeList = declare([_Widget], {
detailPanel: null, // must be passed in at construction time
_onRowClick: function(emp) {
// Direct call — list MUST know about detail panel
this.detailPanel.showEmployee(emp); // breaks if detailPanel is null
this.headerBar.setTitle(emp.name); // now we need headerBar too
this.statusBar.setMessage("Viewing: " + emp.name); // and statusBar
// Every addition requires changing EmployeeList
}
});
// Usage — wiring all the dependencies manually
var list = new EmployeeList({ detailPanel: detail, headerBar: header, statusBar: status });
// If you replace the detail panel, you must update EmployeeList constructor calls everywhere
// DECOUPLED — EmployeeList publishes a topic, knows nothing about consumers
var EmployeeList = declare([_Widget], {
_onRowClick: function(emp) {
// Publish to the bus — no reference to any other widget needed
topic.publish("employee/selected", { employee: emp });
// EmployeeList is now a standalone, independently testable module
}
});
// Each consumer subscribes independently — no changes to EmployeeList needed
// when you add/remove/replace consumers
topic.subscribe("employee/selected", function(data) { detail.show(data.employee); });
topic.subscribe("employee/selected", function(data) { header.setTitle(data.employee.name); });
topic.subscribe("employee/selected", function(data) { analytics.track("view", data.employee.id); });
topic.publish() — Firing Eventsrequire(["dojo/topic"], function(topic) {
// Basic publish — no payload
topic.publish("app/ready");
// Publish with a payload object (recommended: always use an object)
topic.publish("employee/selected", {
employee: { id: 42, name: "Alice Chen", dept: "Engineering" },
source: "listPanel", // optional: who published (useful for debugging)
timestamp: Date.now()
});
// Publish with multiple arguments (less recommended — use object form)
topic.publish("user/login", userId, userName);
// Subscribers receive: function(userId, userName) { ... }
// Publish is SYNCHRONOUS — all subscribers run before publish() returns
console.log("before publish");
topic.publish("data/changed", { id: 1 });
console.log("after publish");
// Output:
// before publish
// [subscriber 1 runs]
// [subscriber 2 runs]
// after publish
});
topic.publish() calls all subscribers immediately and synchronously. If a subscriber throws an error, it can prevent later subscribers from running. Keep subscriber callbacks lean and guard with try/catch in subscriber bodies for critical listeners.
// Inside a widget's method — topic needs to be required as a dependency
define([
"dojo/_base/declare",
"dijit/_Widget",
"dojo/topic"
], function(declare, _Widget, topic) {
return declare([_Widget], {
_onSaveComplete: function(savedEmployee) {
// Notify the rest of the app — list should refresh, status bar should update
topic.publish("employee/saved", {
employee: savedEmployee,
isNew: !savedEmployee._wasExisting
});
},
_onDeleteComplete: function(id) {
topic.publish("employee/deleted", { id: id });
}
});
});
topic.subscribe() — Listening & Cleanuprequire(["dojo/topic", "dojo/_base/lang"], function(topic, lang) {
// Basic subscribe — returns a handle
var handle = topic.subscribe("employee/selected", function(data) {
console.log("Selected:", data.employee.name);
});
// Remove listener when done
handle.remove();
// Subscribe inside a widget — use own() for auto-cleanup
var DetailPanel = declare([_Widget], {
postCreate: function() {
this.inherited(arguments);
// own() auto-removes on widget destroy
this.own(
topic.subscribe("employee/selected",
lang.hitch(this, "_onEmployeeSelected")),
topic.subscribe("employee/saved",
lang.hitch(this, "_onEmployeeSaved")),
topic.subscribe("employee/deleted",
lang.hitch(this, "_onEmployeeDeleted")),
topic.subscribe("app/reset",
lang.hitch(this, "reset"))
);
},
_onEmployeeSelected: function(data) {
this.set("employee", data.employee);
},
_onEmployeeSaved: function(data) {
if (this.employee && this.employee.id === data.employee.id) {
this.set("employee", data.employee); // refresh if showing the saved one
}
},
_onEmployeeDeleted: function(data) {
if (this.employee && this.employee.id === data.id) {
this.reset(); // clear the detail panel
}
},
reset: function() {
this.set("employee", null);
}
});
});
// Subscribe then auto-unsubscribe after first call
var handle;
handle = topic.subscribe("app/initialized", function(data) {
handle.remove(); // remove itself — fires exactly once
initializeUI(data.config);
});
// Development utility — log every publish to console
// Remove before production!
(function() {
var originalPublish = topic.publish;
topic.publish = function(topicName) {
console.log("[topic]", topicName, arguments[1]);
return originalPublish.apply(topic, arguments);
};
})();
Topic names are plain strings — no enforced format. But a consistent convention across your app prevents collisions and makes the event flow readable at a glance.
Recommended pattern: domain/action or domain/entity/action
| Topic Name | Meaning | Published By |
|---|---|---|
| employee/selected | User selected an employee in a list | EmployeeList |
| employee/saved | Employee record was saved to server | EditForm |
| employee/deleted | Employee was deleted | ConfirmDialog |
| filter/changed | User changed a filter value | FilterPanel |
| auth/sessionExpired | Server returned 401 | request/notify handler |
| auth/login | User logged in successfully | LoginForm |
| auth/logout | User logged out | Header / API |
| app/ready | Application fully initialized | boot.js |
| app/error | Unrecoverable application error | Any module |
| nav/tabChanged | Active tab changed | MainTabs |
| data/refreshNeeded | Store data is stale, request refresh | Any module |
// myapp/topics.js — single source of truth for all topic names
define({
EMPLOYEE_SELECTED: "employee/selected",
EMPLOYEE_SAVED: "employee/saved",
EMPLOYEE_DELETED: "employee/deleted",
FILTER_CHANGED: "filter/changed",
AUTH_EXPIRED: "auth/sessionExpired",
AUTH_LOGIN: "auth/login",
APP_READY: "app/ready",
APP_ERROR: "app/error"
});
// Usage in any module — no magic strings, refactor-safe
require(["dojo/topic", "myapp/topics"], function(topic, T) {
topic.publish(T.EMPLOYEE_SELECTED, { employee: emp });
topic.subscribe(T.EMPLOYEE_SAVED, function(data) { ... });
});
Pub/Sub (dojo/topic) | Direct Reference | |
|---|---|---|
| Coupling | None — publisher doesn't know about subscribers | Tight — caller holds a reference |
| Testability | Each module testable in isolation | Must mock/inject dependencies |
| Multiple consumers | Natural — any number of subscribers | Must wire each one manually |
| Debuggability | Harder to trace — "who subscribed to this?" | Easy — call stack shows the path |
| Return values | None — fire and forget | Yes — can return values |
| Ordering | Subscription order — hard to control | Deterministic call order |
| Best for | App-level events, cross-feature communication | Parent-child widget APIs, direct data flow |
// USE pub/sub when:
// - The producer doesn't need to know who consumes the event
// - Multiple independent consumers might respond
// - The consumer is in a different module/feature area
// - You want to add logging, analytics, or side effects without changing the producer
topic.publish("employee/selected", { employee: emp });
// List doesn't care who's listening — it just announces what happened
// USE direct references when:
// - Parent widget controls a child widget directly
// - You need a return value from the call
// - The relationship is always 1:1 and permanent
// - It's an internal widget API (private method)
this.detailPanel.set("employee", emp); // parent owns the detail panel directly
// Clear, traceable, return value available
// When you need a response from a pub/sub call:
// Publish a "request" topic with a callback embedded in the payload
require(["dojo/topic", "dojo/Deferred"], function(topic, Deferred) {
// Module A — needs data from Module B without a direct reference
function requestUserData(userId) {
var deferred = new Deferred();
topic.publish("users/get", {
userId: userId,
callback: function(userData) { deferred.resolve(userData); },
errback: function(err) { deferred.reject(err); }
});
return deferred.promise;
}
// Module B — responds to the request
topic.subscribe("users/get", function(data) {
loadUser(data.userId)
.then(data.callback, data.errback);
});
// Module A usage
requestUserData(42).then(function(user) {
renderCard(user);
});
});
// Centralised app state updated and read via topics
// Useful for global state like current user, active filters, selected record
define([
"dojo/topic",
"dojo/Stateful",
"dojo/_base/declare"
], function(topic, Stateful) {
// App state as an Observable Stateful object
var state = new Stateful({
currentUser: null,
selectedEmployee: null,
activeFilters: {},
isLoading: false
});
// Watch state changes and publish topics so other modules react
state.watch("selectedEmployee", function(prop, old, emp) {
topic.publish("employee/selected", { employee: emp, previous: old });
});
state.watch("isLoading", function(prop, old, loading) {
topic.publish("app/loadingChanged", { loading: loading });
});
// Any module updates state via set() — topics fire automatically
// No module needs to manually call topic.publish() for state changes
return state;
});
// Separate "commands" (what to do) from "events" (what happened)
// Commands: imperative — "please do X"
// Events: declarative — "X happened"
// Commands — published by UI, handled by business logic
topic.publish("cmd/employee/save", { data: formData });
topic.publish("cmd/employee/delete", { id: 42 });
topic.publish("cmd/filter/reset");
// Command handler module
topic.subscribe("cmd/employee/save", function(cmd) {
api.save(cmd.data)
.then(function(saved) {
// Emit event — who cares? Anyone who subscribed
topic.publish("employee/saved", { employee: saved });
})
.otherwise(function(err) {
topic.publish("app/error", { message: err.message, source: "employee/save" });
});
});
// UI subscribes to events, not commands — clean separation
topic.subscribe("employee/saved", function(data) {
refreshList();
showSuccessToast(data.employee.name + " saved");
});
A real-world refactor of a 600-line EmployeeDashboard.js widget into 5 independent modules that communicate only via topics.
// EmployeeDashboard.js — 600 lines, does everything
var EmployeeDashboard = declare([_Widget], {
// List, detail, filters, editing, save/delete — all in one widget
// Impossible to test any feature in isolation
// Changing the list breaks the detail panel
// Adding a new feature requires reading 600 lines to understand context
});
// ── Module 1: EmployeeList ─────────────────────────────────────
define(["dojo/_base/declare","dijit/_Widget","dojo/topic","dojo/on",
"dojo/store/Observable","dojo/store/Memory"],
function(declare, _Widget, topic, on, Observable, Memory) {
return declare([_Widget], {
constructor: function() {
this._store = Observable(new Memory({ data: [] }));
this._currentFilter = {};
},
postCreate: function() {
this.inherited(arguments);
this.own(
topic.subscribe("employee/saved", lang.hitch(this, "_onSaved")),
topic.subscribe("employee/deleted", lang.hitch(this, "_onDeleted")),
topic.subscribe("filter/changed", lang.hitch(this, "_onFilterChanged"))
);
this._renderList();
on(this.domNode, on.selector(".emp-row", "click"), lang.hitch(this, "_onRowClick"));
},
_onRowClick: function() {
var id = +domAttr.get(this, "data-id");
var emp = this._store.get(id);
topic.publish("employee/selected", { employee: emp });
},
_onSaved: function(d) { this._store.put(d.employee); this._renderList(); },
_onDeleted: function(d) { this._store.remove(d.id); this._renderList(); },
_onFilterChanged: function(d) { this._currentFilter = d.filter; this._renderList(); }
});
});
// ── Module 2: DetailPanel ──────────────────────────────────────
define(["dojo/_base/declare","dijit/_Widget","dijit/_TemplatedMixin",
"dojo/topic","dojo/text!./templates/DetailPanel.html"],
function(declare, _Widget, _TemplatedMixin, topic, template) {
return declare([_Widget, _TemplatedMixin], {
templateString: template,
postCreate: function() {
this.inherited(arguments);
this.own(
topic.subscribe("employee/selected", lang.hitch(this, "_show")),
topic.subscribe("employee/saved", lang.hitch(this, "_onSaved"))
);
},
_show: function(data) {
this.set("employee", data.employee);
domStyle.set(this.domNode, "display", "block");
},
_onSaved: function(data) {
if (this.employee && this.employee.id === data.employee.id) {
this.set("employee", data.employee);
}
}
});
});
// ── Module 3: FilterPanel ──────────────────────────────────────
define(["dojo/_base/declare","dijit/_Widget","dijit/form/Select",
"dojo/topic","dojo/on"],
function(declare, _Widget, Select, topic, on) {
return declare([_Widget], {
postCreate: function() {
this.inherited(arguments);
var deptSelect = new Select({
options: [
{ label: "All", value: "" },
{ label: "Engineering", value: "Engineering" },
{ label: "Marketing", value: "Marketing" }
],
onChange: function(val) {
topic.publish("filter/changed", {
filter: val ? { dept: val } : {}
});
}
}).placeAt(this.domNode);
deptSelect.startup();
}
});
});
// ── Module 4: EditForm ─────────────────────────────────────────
define(["dojo/_base/declare","dijit/_Widget","dojo/topic",
"dijit/form/ValidationTextBox","dijit/form/Button"],
function(declare, _Widget, topic, ValidationTextBox, Button) {
return declare([_Widget], {
_currentEmployee: null,
postCreate: function() {
this.inherited(arguments);
this.own(
topic.subscribe("employee/selected", lang.hitch(this, "_populate"))
);
// Save button publishes result — EditForm doesn't touch EmployeeList
on(this.saveBtn, "click", lang.hitch(this, "_onSave"));
},
_populate: function(data) {
this._currentEmployee = data.employee;
this.nameField.set("value", data.employee.name);
},
_onSave: function() {
if (!this.nameField.isValid()) return;
var updated = lang.mixin({}, this._currentEmployee, {
name: this.nameField.get("value")
});
api.save(updated).then(function(saved) {
topic.publish("employee/saved", { employee: saved });
});
}
});
});
// ── Module 5: StatusBar ────────────────────────────────────────
define(["dojo/_base/declare","dijit/_Widget","dojo/topic"],
function(declare, _Widget, topic) {
return declare([_Widget], {
postCreate: function() {
this.inherited(arguments);
this.own(
topic.subscribe("employee/selected", lang.hitch(this, function(d) {
this.domNode.textContent = "Viewing: " + d.employee.name;
})),
topic.subscribe("employee/saved", lang.hitch(this, function(d) {
this.domNode.textContent = "Saved: " + d.employee.name;
setTimeout(lang.hitch(this, function() {
this.domNode.textContent = "Ready";
}), 3000);
}))
);
}
});
});
EmployeeList means publishing mock topics and verifying the DOM — no other widgets needed. Adding a new panel (e.g., an audit log) requires only subscribing to existing topics — no changes to any existing module.
Build two completely independent widgets:
country/selected with the country object.country/selected and renders the country's name, population, and capital. Has no reference to CountryList.Verify: you should be able to swap out CountryList for a CountryMap widget that also publishes country/selected — and CountryDetail would work without any changes.
// topics.js
define({ COUNTRY_SELECTED: "country/selected" });
// CountryList.js
define(["dojo/_base/declare","dijit/_Widget","dojo/topic",
"dojo/dom-construct","dojo/on","myapp/topics"],
function(declare, _Widget, topic, domConstruct, on, T) {
return declare([_Widget], {
countries: [
{ code: "US", name: "United States", pop: 331000000, capital: "Washington D.C." },
{ code: "IN", name: "India", pop: 1380000000,capital: "New Delhi" },
{ code: "DE", name: "Germany", pop: 83000000, capital: "Berlin" }
],
buildRendering: function() {
this.domNode = domConstruct.create("ul", { className: "country-list" });
},
postCreate: function() {
this.inherited(arguments);
var self = this;
this.countries.forEach(function(c) {
var li = domConstruct.create("li", {
textContent: c.name,
"data-code": c.code,
style: "cursor:pointer; padding:8px; border-bottom:1px solid #333"
}, self.domNode);
});
on(this.domNode, on.selector("li", "click"), function() {
var code = domAttr.get(this, "data-code");
var country = self.countries.filter(function(c){ return c.code === code; })[0];
topic.publish(T.COUNTRY_SELECTED, { country: country });
});
}
});
});
// CountryDetail.js
define(["dojo/_base/declare","dijit/_Widget","dojo/topic",
"dojo/dom-construct","myapp/topics"],
function(declare, _Widget, topic, domConstruct, T) {
return declare([_Widget], {
buildRendering: function() {
this.domNode = domConstruct.create("div", { className: "country-detail",
innerHTML: "<p style='color:#64748b'>Select a country</p>" });
},
postCreate: function() {
this.inherited(arguments);
this.own(
topic.subscribe(T.COUNTRY_SELECTED, lang.hitch(this, "_show"))
);
},
_show: function(data) {
var c = data.country;
this.domNode.innerHTML =
"<h3>" + c.name + "</h3>" +
"<p>Capital: <strong>" + c.capital + "</strong></p>" +
"<p>Population: <strong>" + c.pop.toLocaleString() + "</strong></p>";
}
});
});
// boot.js — wire them up without them knowing each other
require(["widgets/CountryList","widgets/CountryDetail"],
function(CountryList, CountryDetail) {
new CountryList().placeAt("leftPanel").startup();
new CountryDetail().placeAt("rightPanel").startup();
// Zero coupling. Replacing CountryList with CountryMap requires 0 changes here.
});
| Concept | Key Rule |
|---|---|
topic.publish(name, data) | Synchronous. Always use an object as payload. Publisher knows nothing about subscribers. |
topic.subscribe(name, fn) | Returns a handle. Always store it — call handle.remove() in destroy. |
this.own(topic.subscribe(...)) | Auto-cleanup on widget destroy. Prefer over manual handle arrays. |
| Topic naming | Use domain/action. Store in a constants module — no magic strings. |
| Pub/Sub vs direct refs | Pub/Sub for cross-feature; direct refs for parent-child widget APIs. |
| Request/response | Embed a callback/deferred in the payload when a response is needed. |
| Command bus | Separate cmd/resource/action (commands) from resource/action (events). |
| State bus | Centralise app state in a Stateful object; watch → publish automatically. |
Time: 4–5 hrs
Pages: 12–15
Case studies: 1
Exercises: 1