BuilderJS

Autosave and a reliable unsaved-changes badge for an embedded email builder

Two features every embedded builder eventually needs: autosave, so a user never loses work, and an honest "unsaved changes" badge, so they know whether it's safe to close the tab. Both are easy to get subtly wrong. The classic bug is the false-dirty warning — the editor flashes "unsaved changes" the instant it loads, before the user has touched anything, because a change signal fires while the design is still being hydrated. BuilderJS gives you the right primitives to handle this cleanly: a debounced DOCUMENT_CHANGED event for "something mutated," and a distinct DOCUMENT_SAVED signal for "we just persisted." BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor for HTML email and pages that you self-host and embed in your own app — it is the editor, not a platform, so it does not send, host, or schedule anything. That division is exactly why this guide is honest about scope: the events are real and shipped, but the throttle, the dirty-state pill, and the beforeunload guard are yours to wire around them. This page shows the precise pattern — subscribe to DOCUMENT_CHANGED to mark dirty and throttle a getData() POST, listen for DOCUMENT_SAVED to clear the flag and toast, and guard the tab close — plus the one real detail most teams miss: load() commits a baseline that fires DOCUMENT_CHANGED, so you need a hydration guard, not a hopeful guess about subscription order.

Mount it

// BuilderJS is loaded as a global script (window.Builder) — no npm import.
  const builder = new window.Builder({
    mainContainer:     '#bjs-canvas',    // required CSS selector string
    widgetsContainer:  '#bjs-widgets',   // optional
    settingsContainer: '#bjs-settings',  // optional
    // saveUrl is optional — builder.save() would POST html + data for you.
    eventDebounceMs: 120,                // default; engine debounces DOCUMENT_CHANGED
  });
  const DEBOUNCE_MS = 120;
  
  let dirty = false;
  let saveTimer = null;
  let saving = false;
  let hydrating = true; // swallow the DOCUMENT_CHANGED that load()'s baseline fires
  
  function setDirty(v) {
    dirty = v;
    document.getElementById('savePill').textContent = v ? 'Unsaved changes' : 'All changes saved';
  }
  
  async function autosave() {
    if (saving) return;            // one in-flight save at a time
    saving = true;
    try {
      const data = builder.getData();              // lossless JSON snapshot
      const res = await fetch('/api/designs/42', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ data }),
      });
      if (!res.ok) throw new Error('save failed: ' + res.status);
      // Engine does NOT auto-fire DOCUMENT_SAVED — you emit it on success.
      builder.events.emit(window.Builder.EVENTS.DOCUMENT_SAVED);
    } finally {
      saving = false;
    }
  }
  
  // 1) Mutations: mark dirty + throttle the save (engine already debounced).
  const onChanged = () => {
    if (hydrating) return;         // ignore the load()-baseline fire
    setDirty(true);
    clearTimeout(saveTimer);
    saveTimer = setTimeout(autosave, 1500);  // save after a 1.5s pause
  };
  
  // 2) Saved: clear dirty + toast (fires for both autosave and Save button).
  const onSaved = () => {
    setDirty(false);
    showToast('Saved ' + new Date().toLocaleTimeString());
  };
  
  builder.events.on(window.Builder.EVENTS.DOCUMENT_CHANGED, onChanged);
  builder.events.on(window.Builder.EVENTS.DOCUMENT_SAVED, onSaved);
  
  // load() commits a baseline that emits a DEBOUNCED DOCUMENT_CHANGED. Clearing
  // the guard must wait until AFTER that ~120ms emit has dispatched — a setTimeout
  // of 0 would clear too early and let the false-dirty through. Subscribing after
  // load() does NOT help: the debounced emit still reaches a late listener.
  builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
  setTimeout(() => { hydrating = false; }, DEBOUNCE_MS + 20);
  
  // Optional manual Save (e.g. Cmd-S): drain the pending autosave first so it
  // can't race your POST, then save + emit DOCUMENT_SAVED yourself.
  // function onCmdS() {
  //   builder.events.flushPending(window.Builder.EVENTS.DOCUMENT_CHANGED);
  //   clearTimeout(saveTimer);
  //   autosave();
  // }
  
  // 3) Tab-close guard — prompts only while there are pending edits.
  const onBeforeUnload = (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } };
  window.addEventListener('beforeunload', onBeforeUnload);
  
  // Teardown (SPA route change / modal close): unsubscribe to avoid leaks.
  // builder.events.off(window.Builder.EVENTS.DOCUMENT_CHANGED, onChanged);
  // builder.events.off(window.Builder.EVENTS.DOCUMENT_SAVED, onSaved);
  // window.removeEventListener('beforeunload', onBeforeUnload);
  // (No destroy() exists — the framework discards the container DOM on unmount.)

Two events, two jobs: DOCUMENT_CHANGED and DOCUMENT_SAVED

BuilderJS exposes a small event bus on the live instance, builder.events, with canonical constants on Builder.EVENTS. Two of them carry the whole autosave/dirty-state story. DOCUMENT_CHANGED fires whenever the design tree commits a mutation — a typed character, a dragged block, a property change, an undo, a programmatic insert. It is debounced by default (120 ms, configurable via new Builder({ eventDebounceMs: ... })), so a burst of typing collapses into one trailing-edge signal instead of a flood. That single property is what makes it safe to hang autosave off: you are not POSTing on every keystroke. DOCUMENT_SAVED is the other half — it marks 'this design is now persisted.' The honest detail to internalize: the engine does not auto-fire DOCUMENT_SAVED for you. It is the constant your code emits (builder.events.emit(Builder.EVENTS.DOCUMENT_SAVED)) right after your save request resolves successfully. Keeping the two signals distinct is the whole trick: 'changed' and 'saved' are separate facts, so your dirty flag becomes the simple difference between them rather than a guess based on timers or HTML diffing. The bus also gives you off() to unsubscribe and flushPending() to fire any debounced DOCUMENT_CHANGED synchronously — handy when a manual Save must not race a pending autosave.

Subscribe to DOCUMENT_CHANGED — mark dirty, throttle the save

On each DOCUMENT_CHANGED fire, do two cheap things: flip a dirty flag (which your badge reads) and schedule a throttled save. Because the event is already debounced inside the engine, your throttle is a second, coarser gate — a typical pattern is a trailing setTimeout of one to a few seconds that you reset on each fire, so autosave runs once the user pauses, not mid-edit. When the timer fires, call builder.getData() to get the lossless JSON snapshot and POST it to your own backend route. (If you also want the rendered markup, builder.getHtml() returns it; for email you'd run that through the export path before sending — but the JSON is your source of truth.) BuilderJS is JSON-first and persistence-agnostic by design: getData() hands the document to your code and your backend decides where it lives — a database column, a file, object storage. Two production niceties: keep a single in-flight save (skip or queue if one is still pending) so a fast typist can't stack requests, and remember the value of the throttle window is a UX call — shorter feels safer, longer is gentler on your server.

Kill the false-dirty-on-load false positive

This is the bug the whole guide exists to prevent, and it is more subtle than 'subscribe at the right time.' DOCUMENT_CHANGED is emitted from the history layer for every committed mutation — and a programmatic builder.load() commits a baseline snapshot at the end of hydration, which counts as a commit. So load() does fire DOCUMENT_CHANGED while restoring a saved design the user never touched. The trap most teams fall into: assuming you can dodge it by subscribing only after load() resolves. You can't. That baseline emit is debounced (120 ms by default), so it dispatches a fraction of a second later — after your post-load subscription is already attached — and your handler runs anyway. Subscription order alone does not save you. The reliable fix is a deterministic hydration guard: set an isHydrating flag before load(), have your DOCUMENT_CHANGED handler ignore fires while it's true, and clear the flag only after the debounce window has passed (e.g. a setTimeout slightly longer than eventDebounceMs) so the baseline fire is swallowed. This anchors 'clean' to a concrete point in time rather than to wall-clock heuristics — and it's exactly why the separate DOCUMENT_SAVED signal matters: you clear dirty on a real saved event, never by trying to diff HTML or compare snapshots to decide whether a change 'counted.'

Clear on DOCUMENT_SAVED, then guard the tab close

Close the loop in two places. First, after your save POST returns success, clear the dirty flag and emit DOCUMENT_SAVED yourself so any other listener (a toast, a 'Last saved 12:04' pill, a header state) updates from one source of truth instead of from inside the fetch callback. Subscribing a toast to DOCUMENT_SAVED keeps your save-button path and your autosave path firing the same feedback without duplicated code. Second, add a window beforeunload guard that prompts only while the dirty flag is set — so a user who has pending, un-POSTed edits gets the browser's native 'leave site?' confirmation, and a user who is fully saved closes the tab with no friction. That guard is honest only because the dirty flag is honest, which is the payoff of the earlier steps. Finally, if your host tears the editor down (SPA route change, modal close), unsubscribe with builder.events.off(Builder.EVENTS.DOCUMENT_CHANGED, handler) using the same function reference so listeners don't leak across re-mounts. Note there is no destroy() method on the instance — on a real unmount your framework discards the container DOM and the editor with it; the only manual cleanup is removing listeners and your beforeunload handler.

What's real vs. what you wire — the honest scope

To be exact about the division of labor: the events are shipped engine behavior, the orchestration is yours. Real and provided by BuilderJS: the builder.events bus, the Builder.EVENTS.DOCUMENT_CHANGED constant (debounced, default 120 ms, eventDebounceMs-configurable, emitted from the history layer on every committed mutation — including the baseline commit during load()), the DOCUMENT_SAVED constant, getData()/getHtml() for serialization, on()/off() for subscribe/teardown, flushPending() to drain a pending debounced change before a manual save, and optional saveUrl + builder.save() which POSTs html and data as url-encoded form fields if you'd rather not hand-roll the fetch. Yours to build: the dirty flag itself, the throttle/timer around the save POST, the visual badge or 'last saved' pill, the toast, the beforeunload guard, and the hydration guard that ignores the load-time fire. There is no built-in 'autosave mode,' no hosted save service, and no save:click event — saving is host-driven precisely so you keep CSRF, routing, slugs, and storage under your own control. That's the BuilderJS contract end to end: it owns the editor and the edit/save signals; you own persistence, UX, and delivery. You buy the source once under a one-time CodeCanyon license, self-host it, and wire these few seams to fit your app.

Frequently asked questions

Explore