PHASE 6

Pub/Sub Architecture with dojo/topic

Decouple every module in your app — publish events, subscribe anywhere, never hold direct references between widgets

dojo/topic Pub/Sub Event Bus Decoupling Module Design
What you'll build by the end: A master-detail panel where the list widget and the detail widget have zero direct references to each other — they communicate entirely through a topic channel. You can replace either widget without touching the other.
1

Why Pub/Sub? The Tight Coupling Problem

In 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); });
EVENT BUS — dojo/topic
EmployeeList → publish employee/selected
employee/selected → subscribe DetailPanel
employee/selected → subscribe HeaderBar
employee/selected → subscribe Analytics
Each module is independently testable — swap any one without touching others
2

topic.publish() — Firing Events

require(["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
});
⚠ GOTCHA — publish() is synchronous
Unlike browser DOM events which queue asynchronously, 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.

Publish From Inside Widgets

// 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 });
    }
  });
});
3

topic.subscribe() — Listening & Cleanup

require(["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);
    }
  });
});

One-Shot Subscription

// 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);
});

Debugging: Logging All Topic Traffic

// 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);
  };
})();
4

Topic Naming Conventions

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 NameMeaningPublished By
employee/selectedUser selected an employee in a listEmployeeList
employee/savedEmployee record was saved to serverEditForm
employee/deletedEmployee was deletedConfirmDialog
filter/changedUser changed a filter valueFilterPanel
auth/sessionExpiredServer returned 401request/notify handler
auth/loginUser logged in successfullyLoginForm
auth/logoutUser logged outHeader / API
app/readyApplication fully initializedboot.js
app/errorUnrecoverable application errorAny module
nav/tabChangedActive tab changedMainTabs
data/refreshNeededStore data is stale, request refreshAny module

Centralising Topic Names — Avoid Magic Strings

// 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) { ... });
});
5

Pub/Sub vs Direct Widget Calls — Tradeoffs

Pub/Sub (dojo/topic)Direct Reference
CouplingNone — publisher doesn't know about subscribersTight — caller holds a reference
TestabilityEach module testable in isolationMust mock/inject dependencies
Multiple consumersNatural — any number of subscribersMust wire each one manually
DebuggabilityHarder to trace — "who subscribed to this?"Easy — call stack shows the path
Return valuesNone — fire and forgetYes — can return values
OrderingSubscription order — hard to controlDeterministic call order
Best forApp-level events, cross-feature communicationParent-child widget APIs, direct data flow

The Right Mental Model

// 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
6

Cross-Module Communication Patterns

Pattern 1: Request/Response via Topics

// 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);
  });
});

Pattern 2: App State Bus

// 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;
});

Pattern 3: Command Bus

// 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");
});
7

Case Study: Splitting a Monolith into 5 Topic-Connected Modules

A real-world refactor of a 600-line EmployeeDashboard.js widget into 5 independent modules that communicate only via topics.

Before: The Monolith

// 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
});

After: 5 Focused Modules

5-MODULE TOPIC BUS
EmployeeList publishes → employee/selected
FilterPanel publishes → filter/changed
EditForm publishes → employee/saved employee/cancelled
employee/selected → subscribed by DetailPanel EditForm
employee/saved → subscribed by EmployeeList StatusBar
filter/changed → subscribed by EmployeeList
// ── 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);
        }))
      );
    }
  });
});
Key insight from this refactor: Each module now has a single responsibility and zero knowledge of any other module. Testing 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.

🔧 Exercise 6.8 — Master-Detail Panel via Topics Only

Build two completely independent widgets:

  • CountryList — displays a list of countries. On click, publishes country/selected with the country object.
  • CountryDetail — subscribes to 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.
});

Phase 6 — Quick Reference

ConceptKey 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 namingUse domain/action. Store in a constants module — no magic strings.
Pub/Sub vs direct refsPub/Sub for cross-feature; direct refs for parent-child widget APIs.
Request/responseEmbed a callback/deferred in the payload when a response is needed.
Command busSeparate cmd/resource/action (commands) from resource/action (events).
State busCentralise app state in a Stateful object; watch → publish automatically.

Ready for Phase 7?

Pub/Sub mastered. Next: dgrid — Dojo's most powerful data grid with inline editing, sorting, pagination, and live store binding.

Continue to Phase 7: dgrid Advanced Grid →