PHASE 3

DOM, Events & Data Binding

dojo/dom, dojo/on, dojo/query, NodeList, dojo/store — the complete toolkit for working with the DOM and live data

dojo/dom dojo/on dojo/query NodeList dojo/store Observable Stateful
What you'll build by the end: A live-filtering employee table driven entirely by an Observable Memory store — no manual DOM diffing, no jQuery, no framework magic. The store notifies the UI; the UI never polls the store.
1

dojo/dom — Core DOM Utilities

Dojo wraps basic DOM operations in cross-browser modules. In modern browsers (IE9+) you could use native APIs directly, but using Dojo's wrappers keeps your code testable and consistent across the codebase.

dom.byId(id)

Same as document.getElementById. Accepts string or DOM node (pass-through).

dom.isDescendant(a, b)

Returns true if node a is inside node b.

dom.setSelectable(node, v)

Enable/disable text selection on a node. Used in drag handles.

domAttr.get/set/has/remove

dojo/dom-attr — cross-browser attribute & property access.

require([
  "dojo/dom",
  "dojo/dom-attr",
  "dojo/dom-geometry"
], function(dom, domAttr, domGeom) {

  // byId — accepts string id or existing DOM node (safe pass-through)
  var el = dom.byId("myPanel");

  // dom-attr: get/set HTML attributes and DOM properties
  domAttr.set(el, "aria-expanded", "true");
  domAttr.set(el, { "data-role": "panel", tabIndex: 0 });
  var role = domAttr.get(el, "data-role");   // "panel"
  domAttr.remove(el, "hidden");

  // dom-geometry: position and size (layout-safe)
  var pos  = domGeom.position(el);           // { x, y, w, h }
  var box  = domGeom.getContentBox(el);      // { l, t, w, h } — content area
  var mbox = domGeom.getMarginBox(el);       // includes margin

  console.log("Element is", box.w, "x", box.h, "at", pos.x, pos.y);
});
⚠ GOTCHA — dom.byId vs dijit.byId
dom.byId("myWidget") returns the DOM node.
dijit.byId("myWidget") returns the widget instance.
These are completely different objects. Calling .set() on a DOM node fails; calling .style on a widget instance returns undefined.
2

dojo/dom-class — CSS Class Manipulation

require(["dojo/dom-class", "dojo/dom"], function(domClass, dom) {

  var el = dom.byId("statusBar");

  // add / remove / contains
  domClass.add(el, "is-active");
  domClass.remove(el, "is-loading");
  var hasError = domClass.contains(el, "has-error");   // boolean

  // toggle — add if absent, remove if present
  domClass.toggle(el, "is-expanded");

  // toggle with explicit force value (like classList.toggle(cls, bool))
  domClass.toggle(el, "is-selected", true);    // always add
  domClass.toggle(el, "is-selected", false);   // always remove

  // replace — swap one class for another atomically
  domClass.replace(el, "status-success", "status-error");

  // Add multiple classes at once
  domClass.add(el, ["card", "card-dark", "elevated"]);
});

Using dojo/dom-class instead of raw className manipulation is important because:

  • It handles the space-separated string correctly even on IE8
  • It never accidentally duplicates class names
  • It works with any DOM node — including SVG elements
3

dojo/dom-style — Style Reads & Writes

require(["dojo/dom-style", "dojo/dom"], function(domStyle, dom) {

  var el = dom.byId("panel");

  // Set a single style
  domStyle.set(el, "display", "none");

  // Set multiple styles at once (object form)
  domStyle.set(el, {
    opacity:    0.8,
    transform:  "translateX(0)",
    transition: "transform 0.3s ease"
  });

  // Get a COMPUTED style value (not the inline style — the actual rendered value)
  var opacity = domStyle.get(el, "opacity");
  var display = domStyle.get(el, "display");  // "block", "flex", etc.

  console.log("opacity:", opacity);   // 0.8
  console.log("display:", display);   // computed value, not inline value
});
⚠ WHY NOT el.style.display = "none"?
Raw el.style only reads/writes inline styles. domStyle.get() reads the computed style — the actual rendered value after CSS cascade. Always use domStyle.get() when you need to know what the element actually looks like on screen.

Showing and Hiding — The Right Way

// For plain DOM nodes:
domStyle.set(el, "display", "none");
domStyle.set(el, "display", "block");

// For Dijit widgets — NEVER manipulate domNode.style directly for visibility
// The widget may need to recalculate layout when shown
var widget = dijit.byId("myPanel");
widget.set("style", "display:none");
// or better: use the widget's own hide/show if it has one
// e.g., dijit/layout/ContentPane has no built-in hide, use domStyle on its domNode
// but ALWAYS call layout() on parent containers after showing a layout widget
4

dojo/dom-construct — Create & Place

require(["dojo/dom-construct", "dojo/dom"], function(domConstruct, dom) {

  // create(tag, attrs, refNode, position)
  var li = domConstruct.create("li", {
    innerHTML: "New Item",
    className: "list-item",
    "data-id":  "42"
  }, dom.byId("myList"), "last");
  // position: "last"(default), "first", "before", "after", "replace", "only"

  // place — move an existing node
  domConstruct.place(li, dom.byId("otherList"), "first");

  // toDom — parse an HTML string into a DOM node/fragment
  var frag = domConstruct.toDom(
    "<div class='card'><h3>Title</h3><p>Body text</p></div>"
  );
  domConstruct.place(frag, dom.byId("container"), "last");

  // destroy — remove node AND its children from DOM (and memory)
  domConstruct.destroy(li);

  // empty — remove all children but keep the node itself
  domConstruct.empty(dom.byId("myList"));
});

Building Complex Structures Efficiently

// Build a card list from data — use a DocumentFragment for performance
var data = [
  { id: 1, name: "Alice", dept: "Engineering" },
  { id: 2, name: "Bob",   dept: "Marketing" }
];

var container = dom.byId("cardContainer");
var frag = document.createDocumentFragment();

data.forEach(function(person) {
  var card = domConstruct.toDom(
    '<div class="person-card" data-id="' + person.id + '">' +
      '<strong>' + person.name + '</strong>' +
      '<span class="dept">' + person.dept + '</span>' +
    '</div>'
  );
  frag.appendChild(card);
});

// Single DOM insertion — one reflow instead of N
container.appendChild(frag);
5

dojo/on — Event Binding

dojo/on is the modern Dojo event system. It wraps addEventListener with cross-browser normalization and returns a handle object you can remove later.

require([
  "dojo/on",
  "dojo/dom",
  "dojo/_base/lang"
], function(on, dom, lang) {

  var btn = dom.byId("submitBtn");

  // Basic binding — returns a handle
  var handle = on(btn, "click", function(e) {
    e.preventDefault();
    console.log("clicked");
  });

  // Remove listener — ALWAYS store handles in widgets
  handle.remove();

  // Multiple events on one node
  var handles = [
    on(btn, "click",     lang.hitch(widget, "_onClick")),
    on(btn, "mouseover", lang.hitch(widget, "_onHover")),
    on(btn, "keydown",   lang.hitch(widget, "_onKey"))
  ];
  // Remove all at once
  handles.forEach(function(h) { h.remove(); });

  // Once — fires exactly once, then auto-removes
  on.once(btn, "click", function(e) {
    initializeOnFirstClick();
  });

  // Emit a synthetic event
  on.emit(btn, "click", { bubbles: true, cancelable: true });
});

Event Delegation — Efficient List Handling

// WRONG — attaching a listener to every list item
// If the list has 500 items → 500 event listeners
query(".list-item").forEach(function(item) {
  on(item, "click", handler);  // expensive, breaks when items are added
});

// CORRECT — single delegated listener on the parent
// Works for items added dynamically; one listener for all items
var list = dom.byId("employeeList");
on(list, on.selector(".list-item", "click"), function(e) {
  // 'this' is the matched .list-item element
  var id = domAttr.get(this, "data-id");
  loadEmployee(id);
});

// Selector-based delegation syntax:
// on(parent, on.selector(cssSelector, eventName), handler)

Widget-Emitted Events

// Widgets emit custom events via this.emit()
// Listen to them the same way as DOM events

var myCard = new ProfileCard({ name: "Alice" }).placeAt("app");
myCard.startup();

// Listen to a widget's custom event
myCard.on("select", function(e) {
  console.log("Selected:", e.name, "→", e.selected);
});

// Emit from inside the widget:
// this.emit("select", { name: this.name, selected: true });
// The event bubbles up through the DOM like a native event
6

dojo/on vs Legacy dojo.connect

Legacy Dojo code uses dojo.connect() and dojo.disconnect(). You will encounter these in older codebases. Understand the differences so you can safely migrate or interop with them.

dojo/on (modern)dojo.connect (legacy)
TargetDOM nodes, widgets, any event emitterAny object method — DOM or non-DOM
Cleanuphandle.remove()dojo.disconnect(handle)
DelegationYes — on.selector()No
OnceYes — on.once()No
Scope of "this"Event target (DOM) or emitterExplicitly specified or global
Non-DOM method hooksNoYes — can hook into any object method
// Legacy dojo.connect — hooking any object method (not just DOM events)
var handle = dojo.connect(myStore, "onSet", function(item, attr, oldVal, newVal) {
  console.log("Store item changed:", attr, oldVal, "→", newVal);
});
dojo.disconnect(handle);

// Modern equivalent using dojo/aspect (for method hooking)
require(["dojo/aspect"], function(aspect) {
  var handle = aspect.after(myStore, "put", function(result) {
    console.log("Store.put completed:", result);
    return result;  // must return in aspect.after
  });
  handle.remove();
});

// Rule: use dojo/on for events, dojo/aspect for method interception
7

Handle Management & Cleanup

Every dojo/on call, every topic.subscribe(), every store.query().observe() returns a handle. Failing to remove these handles when a widget is destroyed is the primary source of memory leaks in Dojo apps.

// Pattern 1: _handles array (most common in widgets)
var MyWidget = declare([_Widget], {
  constructor: function() {
    this._handles = [];
  },
  postCreate: function() {
    this.inherited(arguments);
    this._handles.push(
      on(this.domNode, "click",  lang.hitch(this, "_onClick")),
      on(window, "resize",       lang.hitch(this, "_onResize")),
      topic.subscribe("data/changed", lang.hitch(this, "_onDataChanged"))
    );
  },
  destroy: function(preserveDom) {
    this._handles.forEach(function(h) { h.remove(); });
    this._handles = [];
    this.inherited(arguments);
  }
});
// Pattern 2: own() — built into dijit/_Widget
// Automatically removes handles when widget is destroyed
// No need for manual _handles array

var MyWidget = declare([_Widget], {
  postCreate: function() {
    this.inherited(arguments);

    // own() returns the handle AND registers it for auto-cleanup
    this.own(
      on(this.domNode, "click",  lang.hitch(this, "_onClick")),
      topic.subscribe("app/reset", lang.hitch(this, "_reset"))
    );
    // When destroyRecursive() is called, all own()'d handles are removed
  }
  // No destroy() override needed for these handles!
});
Best practice: Use this.own() for handles inside widgets — it's cleaner and safer than maintaining a manual _handles array. Use the manual array pattern only when you need to remove specific handles before the widget is destroyed.

Combining Multiple Handles — dojo/promise/all style

// When you want to remove a GROUP of handles together
require(["dojo/_base/lang"], function(lang) {

  function makeHandleGroup(handles) {
    return {
      remove: function() {
        handles.forEach(function(h) { h.remove(); });
        handles = [];
      }
    };
  }

  var searchHandles = makeHandleGroup([
    on(searchInput, "input",  onSearchInput),
    on(searchInput, "keydown", onSearchKeydown),
    on(clearBtn,    "click",   onClearClick)
  ]);

  // Later — remove the whole group atomically
  searchHandles.remove();
});
8

dojo/query + NodeList

dojo/query returns a NodeList — an array-like collection of DOM nodes with chainable methods for batch operations. Think of it as Dojo's jQuery-style DOM selector.

require(["dojo/query"], function(query) {

  // Basic CSS selector → NodeList
  var items = query(".list-item");
  console.log(items.length);   // number of matched nodes

  // Scoped query — search within a subtree
  var container = dom.byId("userList");
  var activeItems = query(".list-item.is-active", container);

  // NodeList is array-like — use forEach, filter, map
  items.forEach(function(node, index) {
    console.log(index, node.textContent);
  });

  // filter returns a new NodeList
  var highlighted = items.filter(function(node) {
    return domClass.contains(node, "highlight");
  });

  // at(index) — returns NodeList with single element
  var first = items.at(0);

  // Chaining (requires NodeList-dom plugin loaded)
  query(".card")
    .addClass("animated")
    .style("opacity", "0")
    .forEach(function(node) { fadeIn(node); });
});

Complex Selectors

// dojo/query supports the full CSS3 selector spec
query("table.data-table > tbody > tr:nth-child(odd)").addClass("alt-row");
query("input[type='checkbox']:checked").forEach(collectSelected);
query(".panel:not(.collapsed) .panel-body").style("display", "block");
query("[data-role='admin']").addClass("admin-highlight");

// Attribute selectors
query("[data-status='pending']").addClass("needs-attention");
query("[data-id]").forEach(function(node) {
  console.log(domAttr.get(node, "data-id"));
});
9

NodeList Plugins

NodeList methods are extended by requiring plugin modules. Each plugin adds a new set of methods to every NodeList in your module.

Plugin ModuleMethods AddedUse For
dojo/NodeList-domaddClass, removeClass, toggleClass, style, attr, place, orphan, adopt, empty, destroyDOM manipulation on query results
dojo/NodeList-traversechildren, parent, parents, siblings, next, prev, first, last, closestNavigating the DOM tree
dojo/NodeList-manipulateinnerHTML, text, val, append, prepend, before, after, wrap, wrapAll, cloneContent manipulation
dojo/NodeList-eventson, connectEvent binding on query results
dojo/NodeList-fxfadeIn, fadeOut, slideTo, wipeIn, wipeOutAnimations on query results
// Load plugins once — methods available on all NodeLists in this module
require([
  "dojo/query",
  "dojo/NodeList-dom",
  "dojo/NodeList-traverse",
  "dojo/NodeList-manipulate"
], function(query) {

  // NodeList-dom: addClass, style, attr
  query(".card")
    .addClass("visible")
    .style({ opacity: 1, transform: "translateY(0)" });

  // NodeList-traverse: parent, children, siblings
  query(".selected-row")
    .parent()                           // → table tbody
    .children(".data-row")              // → all sibling rows
    .removeClass("highlight");

  query(".error-input")
    .closest(".form-group")             // → containing form group
    .addClass("has-error");

  // NodeList-manipulate: text, append, clone
  query(".status-badge").text("Loading...");

  query(".template-row")
    .clone()                            // copy the template row
    .attr("data-id", newItem.id)
    .find(".cell-name")
      .text(newItem.name)
    .end()                              // back to the cloned row
    .place(dom.byId("tbody"), "last");
});
10

dojo/store/Memory — CRUD Operations

dojo/store/Memory is an in-memory data store implementing the dojo/store API. It provides a consistent interface for querying and modifying data — the same API that dgrid, dijit/form/Select, and other widgets consume.

require(["dojo/store/Memory"], function(Memory) {

  var store = new Memory({
    idProperty: "id",     // which field is the unique key (default: "id")
    data: [
      { id: 1, name: "Alice Chen",  dept: "Engineering", salary: 95000, active: true },
      { id: 2, name: "Bob Smith",   dept: "Marketing",   salary: 72000, active: true },
      { id: 3, name: "Carol Davis", dept: "Engineering", salary: 105000, active: false },
      { id: 4, name: "Dave Wilson", dept: "HR",          salary: 68000, active: true }
    ]
  });

  // ── READ ────────────────────────────────────────────────────────
  var alice = store.get(1);               // { id:1, name:"Alice Chen", ... }
  var all   = store.query({});            // all records
  var eng   = store.query({ dept: "Engineering" });     // filtered
  var active = store.query({ active: true });

  // Query with sort
  var sorted = store.query({}, {
    sort: [{ attribute: "salary", descending: true }]
  });

  // Query with pagination
  var page1 = store.query({}, { start: 0, count: 10 });
  var page2 = store.query({}, { start: 10, count: 10 });

  // Custom filter function
  var highEarners = store.query(function(item) {
    return item.salary > 90000 && item.active;
  });

  // forEach on query results
  eng.forEach(function(emp) {
    console.log(emp.name, emp.salary);
  });

  // ── CREATE ───────────────────────────────────────────────────────
  store.add({
    id: 5, name: "Eve Martinez", dept: "Engineering", salary: 88000, active: true
  });
  // add() throws if id already exists; put() upserts

  // ── UPDATE ───────────────────────────────────────────────────────
  store.put({ id: 1, name: "Alice Chen", dept: "Engineering", salary: 98000, active: true });
  // put() replaces the entire object — include ALL fields

  // Partial update pattern (fetch → merge → put)
  var alice = store.get(1);
  alice.salary = 98000;
  store.put(alice);

  // ── DELETE ───────────────────────────────────────────────────────
  store.remove(2);   // removes item with id 2

  console.log("Total count:", store.query({}).total);
});

Custom Query Engines

// SimpleQueryEngine (default) supports: exact match, regex, function
// For advanced queries, replace the queryEngine:

require(["dojo/store/Memory", "dojo/store/util/SimpleQueryEngine"],
function(Memory, SimpleQueryEngine) {

  var store = new Memory({
    data: employees,
    queryEngine: function(query, options) {
      // Custom engine: support partial string match on 'name'
      return SimpleQueryEngine(function(item) {
        if (typeof query === "string") {
          return item.name.toLowerCase().indexOf(query.toLowerCase()) > -1;
        }
        return SimpleQueryEngine(query)(item);
      }, options);
    }
  });

  // Now string queries do partial name match
  store.query("ali");   // matches "Alice Chen"
});
11

dojo/store/Observable — Live Query Results

Observable wraps any store and makes query results live. When you call observe() on a query result, your callback fires automatically whenever an item matching the query is added, updated, or removed.

require([
  "dojo/store/Memory",
  "dojo/store/Observable"
], function(Memory, Observable) {

  // Wrap store with Observable
  var store = Observable(new Memory({ data: employees }));

  // Query returns a live result set
  var engQuery = store.query({ dept: "Engineering" });

  // observe(callback, includeObjectUpdates)
  var observeHandle = engQuery.observe(function(item, removedFrom, insertedAt) {
    //
    // removedFrom: index where item was REMOVED (-1 if not removed)
    // insertedAt:  index where item was INSERTED (-1 if not inserted)
    //
    // Case 1: New item added to query results
    if (removedFrom === -1 && insertedAt >= 0) {
      console.log("Added to Engineering:", item.name, "at index", insertedAt);
      renderNewRow(item, insertedAt);
    }
    // Case 2: Item removed from query results (deleted or dept changed)
    else if (removedFrom >= 0 && insertedAt === -1) {
      console.log("Removed from Engineering:", item.name);
      removeRow(item.id);
    }
    // Case 3: Item updated and still in query results (moved position)
    else if (removedFrom >= 0 && insertedAt >= 0) {
      console.log("Updated in Engineering:", item.name);
      updateRow(item);
    }
  }, true);   // true = also notify on property changes within matched items

  // Mutations trigger the observe callback automatically:
  store.add({ id: 5, name: "Eve", dept: "Engineering", salary: 88000 });
  // → callback fires: removedFrom=-1, insertedAt=2

  store.put({ id: 1, name: "Alice Chen", dept: "Marketing", salary: 95000 });
  // → callback fires: removedFrom=0, insertedAt=-1 (moved OUT of Engineering)

  store.remove(3);
  // → callback fires for Carol if she was in Engineering

  // Always clean up the observe handle
  observeHandle.remove();
});
The power of Observable: Your UI code never needs to know when data changes. It just reacts. This is Dojo's answer to reactive data binding — before React/Vue made it fashionable. Wire observe() to your render function and your list always reflects the store state automatically.
12

dojo/Stateful — Property Watching

dojo/Stateful is a mixin that adds a watch() method to any object. When a watched property changes via set(), all watchers are notified. This is Dojo's observable object pattern — used by all Dijit widgets.

require(["dojo/Stateful", "dojo/_base/declare"], function(Stateful, declare) {

  // Simple Stateful object
  var model = new Stateful({
    name:   "Alice",
    status: "active",
    score:  0
  });

  // watch(propName, callback) — fires when property changes
  var nameWatcher = model.watch("name", function(propName, oldVal, newVal) {
    console.log("name changed:", oldVal, "→", newVal);
    document.title = newVal + " | Dashboard";
  });

  var scoreWatcher = model.watch("score", function(prop, old, newVal) {
    if (newVal > 100) {
      model.set("status", "expert");  // triggers status watchers too
    }
  });

  // watch all properties
  var allWatcher = model.watch(function(propName, oldVal, newVal) {
    console.log("[model]", propName, ":", oldVal, "→", newVal);
  });

  // Trigger watchers via set()
  model.set("name", "Alice Chen");    // → name changed: Alice → Alice Chen
  model.set("score", 150);            // → score watcher fires, status set

  // get() — read current value
  console.log(model.get("status"));   // "expert"

  // Remove individual watchers
  nameWatcher.remove();
  scoreWatcher.remove();
  allWatcher.remove();

  // Using Stateful in a declare class
  var UserModel = declare([Stateful], {
    name:   "",
    email:  "",
    role:   "viewer",

    constructor: function(props) {
      // set() triggers watchers even in constructor
      this.set(props || {});
    },

    isAdmin: function() {
      return this.get("role") === "admin";
    }
  });

  var user = new UserModel({ name: "Bob", role: "admin" });

  user.watch("role", function(prop, old, newVal) {
    console.log("Role changed to:", newVal);
    updatePermissions(newVal);
  });

  user.set("role", "viewer");   // → watcher fires
});
MechanismScopeUse For
Stateful.watch()Single object propertyModel/ViewModel property changes
Observable.observe()Store query results (collection)Live list/grid updates
dojo/topic.subscribe()App-wide named channelCross-module communication
widget._setXxxAttrWidget propertyReactive widget rendering
13

Demo App: Live-Filtering Employee Table

This demo wires a search input directly to an Observable store query. The table updates in real time — no manual DOM scanning, no timeouts, no jQuery.

// Live result — try filtering by "Engineering"
Alice ChenEngineering$95,000
Bob SmithMarketing$72,000
Carol DavisEngineering$105,000
Dave WilsonHR$68,000
<!-- index.html -->
<input id="searchInput" type="text" placeholder="Filter by name or department...">
<div id="statsBar"></div>
<table>
  <thead><tr><th>Name</th><th>Dept</th><th>Salary</th></tr></thead>
  <tbody id="employeeTableBody"></tbody>
</table>
// employeeTable.js
define([
  "dojo/store/Memory",
  "dojo/store/Observable",
  "dojo/dom",
  "dojo/dom-construct",
  "dojo/dom-class",
  "dojo/on",
  "dojo/_base/lang",
  "dojo/domReady!"
], function(Memory, Observable, dom, domConstruct, domClass, on, lang) {

  var employees = [
    { id: 1, name: "Alice Chen",   dept: "Engineering", salary: 95000 },
    { id: 2, name: "Bob Smith",    dept: "Marketing",   salary: 72000 },
    { id: 3, name: "Carol Davis",  dept: "Engineering", salary: 105000 },
    { id: 4, name: "Dave Wilson",  dept: "HR",          salary: 68000 },
    { id: 5, name: "Eve Martinez", dept: "Engineering", salary: 88000 },
    { id: 6, name: "Frank Lee",    dept: "Marketing",   salary: 76000 }
  ];

  var store = Observable(new Memory({ data: employees }));
  var tbody = dom.byId("employeeTableBody");
  var statsBar = dom.byId("statsBar");
  var currentQuery = null;   // holds the active observe handle
  var currentFilter = "";

  // ── Render ────────────────────────────────────────────────
  function renderRow(emp) {
    return domConstruct.create("tr", {
      "data-id": emp.id,
      innerHTML:
        "<td>" + emp.name + "</td>" +
        "<td><span class='dept-badge dept-" + emp.dept.toLowerCase() + "'>"
          + emp.dept + "</span></td>" +
        "<td>$" + emp.salary.toLocaleString() + "</td>"
    });
  }

  function updateStats(results) {
    statsBar.textContent =
      results.length + " employee" + (results.length !== 1 ? "s" : "") + " shown";
  }

  // ── Query + Observe ───────────────────────────────────────
  function applyFilter(text) {
    // Clean up previous observe handle
    if (currentQuery) { currentQuery.remove(); }
    domConstruct.empty(tbody);

    // Build query function for partial match on name or dept
    var queryFn = text
      ? function(item) {
          var t = text.toLowerCase();
          return item.name.toLowerCase().indexOf(t) > -1 ||
                 item.dept.toLowerCase().indexOf(t) > -1;
        }
      : {};   // empty object = return all

    var results = store.query(queryFn, { sort: [{ attribute: "name" }] });

    // Initial render
    results.forEach(function(emp) {
      domConstruct.place(renderRow(emp), tbody, "last");
    });
    updateStats(results);

    // Live updates via observe
    currentQuery = results.observe(function(item, removedFrom, insertedAt) {
      var existingRow = tbody.querySelector("[data-id='" + item.id + "']");

      if (removedFrom >= 0 && existingRow) {
        // Briefly highlight then remove
        domClass.add(existingRow, "row-removing");
        setTimeout(function() { domConstruct.destroy(existingRow); }, 300);
      }

      if (insertedAt >= 0) {
        var newRow = renderRow(item);
        if (insertedAt >= tbody.children.length) {
          domConstruct.place(newRow, tbody, "last");
        } else {
          domConstruct.place(newRow, tbody.children[insertedAt], "before");
        }
        domClass.add(newRow, "row-inserting");
        setTimeout(function() { domClass.remove(newRow, "row-inserting"); }, 300);
      }

      updateStats(store.query(queryFn));
    }, true);
  }

  // ── Wire search input ─────────────────────────────────────
  var searchHandle = on(dom.byId("searchInput"), "input", lang.hitch(null, function(e) {
    applyFilter(e.target.value.trim());
  }));

  // ── Initial render ────────────────────────────────────────
  applyFilter("");

  // ── Demo: simulate live data change after 3 seconds ──────
  setTimeout(function() {
    // Update Alice's salary — observe() fires, row updates live
    var alice = store.get(1);
    alice.salary = 102000;
    store.put(alice);
  }, 3000);

  // Expose store for console debugging
  return { store: store, applyFilter: applyFilter };
});
Architecture insight: The store is the single source of truth. The UI never directly modifies the DOM in response to user actions — it always goes through the store. When store.put() fires, the observe() callback updates the DOM. This one-directional data flow prevents sync bugs and makes the code easy to test.

Phase 3 — Quick Reference

ModuleKey MethodsRemember
dojo/dombyId, isDescendantdom.byIddijit.byId
dojo/dom-classadd, remove, toggle, contains, replaceUse over raw className
dojo/dom-styleget, setget() returns computed, not inline
dojo/dom-constructcreate, place, toDom, destroy, emptyBatch with DocumentFragment
dojo/onon(), on.once(), on.selector(), on.emit()Always store and remove handles
dojo/aspectbefore, after, aroundMethod interception (not DOM events)
dojo/queryCSS selector → NodeListLoad NodeList-* plugins for chaining
dojo/store/Memoryget, query, add, put, removeput() replaces entire object
dojo/store/Observableobserve(fn, includeUpdates)removedFrom=-1 → insert; insertedAt=-1 → delete
dojo/Statefulget, set, watchUse set() to trigger watchers; direct assign bypasses them

Ready for Phase 4?

DOM and data covered. Next: the full Dijit widget library — 12 form widgets, 5 layout widgets, dialogs, menus, and the theme system.

Continue to Phase 4: Dijit Widget Library →