Skip to main content

Reactivity & Events

Building editors with Ydesign usually boils down to three kinds of question:

  1. "The canvas changed — how do I refresh my UI automatically?" → Reactivity
  2. "The user selected / edited / undid something — how do I catch that action?" → Events
  3. "I only care about one specific property changing — don't drown me in noise." → Derived subscriptions

This page covers the recommended patterns and the real APIs for each.


React Hooks + MobX reactivity

store is a MobX-State-Tree instance; @ydesign/react-editor uses mobx-react-lite's observer HOC internally so components subscribe automatically.

Read store like it's local state

import { observer } from 'mobx-react-lite';

const StatusBar = observer(({ store }) => {
// Whatever you read here, the component subscribes to
return (
<div>
Canvas: {store.width} × {store.height}px ·
{store.selectedElementsIds.length} selected ·
Zoom {Math.round(store.scale * 100)}%
</div>
);
});

Rules that matter:

  • ✅ MobX tracks per-field — read only what you need
  • ✅ Only re-renders when a field you actually read changes
  • Don't forget observer, or the component never updates
  • ❌ Don't stash field reads inside useMemo / useCallback or one-shot blocks — you'll lose the subscription

Manual subscriptions: useEffect + reaction

Sometimes you don't want to render UI, you want to run a side effect when a value changes (auto-save, analytics, etc.):

import { useEffect } from 'react';
import { reaction } from 'mobx';

function AutoSave({ store }) {
useEffect(() => {
const dispose = reaction(
// 1) source: whenever this return value changes, run (2)
() => store.toJSON(),
// 2) effect
(json) => {
fetch('/api/designs/current', {
method: 'PUT',
body: JSON.stringify(json),
});
},
{ delay: 1000 }, // 1s throttle
);

return dispose; // unsubscribe on unmount
}, [store]);

return null;
}

autorun: runs immediately, then on every tracked change

import { autorun } from 'mobx';

const dispose = autorun(() => {
// Runs once on mount; reruns whenever a tracked field changes
if (store.selectedElementsIds.length === 0) {
store.openSidePanel('templates');
}
});

// When done:
dispose();

Event bus (store.editor)

@ydesign/core's Editor instance is an event emitter (a small internal EventManager). Handlers emit events at key moments, and you subscribe to them for business reactions.

API

// Subscribe
store.editor?.on('event-name', handler);

// Unsubscribe (or omit handler to clear all of a type)
store.editor?.off('event-name', handler);

// Wildcard — receive every event
store.editor?.on('*', (type, evt) => {
console.log(type, evt);
});

💡 on returns void. Keep a reference to your handler externally so you can pass it to off — same discipline as the DOM addEventListener.

Built-in events (from the real source)

EventWhen it firesPayload
object:modifiedElement properties changed (including lock / unlock){ target, action }
textbox:modifiedTextbox scale / text content changed{ target, action }
workarea:changedWorkarea size or background changed; also fires on undo/redo{ target, action }
workarea:max-sizeCanvas size hit the resize cap{ maxWidth, maxHeight }
history:changedHistory stack changed (push / undo / redo){ type, backgroundColor?, ... }
inpaint:activateEntered AI eraser mode
inpaint:deactivateExited AI eraser mode
inpaint:statusAI eraser state updated{ canUndo, canRedo, active }

Common patterns

① Track element edits for analytics

store.editor?.on('object:modified', ({ target, action }) => {
window.plausible?.('element-modified', { props: { type: target.type, action } });
});

② Mirror workarea changes to your app state

store.editor?.on('workarea:changed', ({ target }) => {
console.log('canvas size changed', target.width, target.height);
setDirty(true);
});

③ Temporarily hide the toolbar while the eraser is active

const [inpainting, setInpainting] = useState(false);

useEffect(() => {
if (!store.editor) return;
const onIn = () => setInpainting(true);
const onOut = () => setInpainting(false);
store.editor.on('inpaint:activate', onIn);
store.editor.on('inpaint:deactivate', onOut);
return () => {
store.editor?.off('inpaint:activate', onIn);
store.editor?.off('inpaint:deactivate', onOut);
};
}, [store.editor]);

④ Debug with the '*' wildcard — see every event

store.editor?.on('*', (type, evt) => {
console.log('[editor]', type, evt);
});

store.on('change', cb) — high-level "canvas changed"

On top of the low-level events, store exposes a simple "something on the canvas changed" hook. Backed by MST's onSnapshot with a deep-equal check, so it only fires on real changes:

const dispose = store.on('change', (objects) => {
console.log(`${objects.length} objects on the canvas now`);
});

// When done:
dispose();

Good for auto-save, dirty tracking, "preview JSON" drawers.

Compared with store.editor.on('object:modified', ...):

  • store.on('change') — debounced / merged, good for "final state" detection
  • store.editor.on('object:modified') — fires every time, good for fine-grained analytics

Detecting specific actions

Knowing something changed isn't enough — often you want to know what changed and who did it. A few patterns:

Diff old vs new with reaction

import { reaction } from 'mobx';

reaction(
() => store.selectedElementsIds.slice(),
(current, previous) => {
const added = current.filter(id => !previous.includes(id));
const removed = previous.filter(id => !current.includes(id));
console.log('newly selected', added, 'deselected', removed);
},
);

Switch on the event's action field

Built-in events carry an action field telling you what kind of change happened:

store.editor?.on('object:modified', ({ target, action }) => {
switch (action) {
case 'lock':
console.log(target.id, 'locked');
break;
case 'unlock':
console.log(target.id, 'unlocked');
break;
case 'text:scale':
console.log('text scaled');
break;
default:
console.log('normal edit');
}
});

Tell user edits from undo / redo

Undo and redo also emit history:changed — use it to separate them from regular edits:

let isUndoRedo = false;

store.editor?.on('history:changed', (data) => {
isUndoRedo = data?.type === 'undo' || data?.type === 'redo';
});

store.editor?.on('object:modified', () => {
if (isUndoRedo) {
// The modification came from undo/redo — don't count it as a new user edit
return;
}
trackUserEdit();
});

Mix MobX and events

A common pattern: MobX for UI (declarative), events for business (imperative side effects).

// UI: the button label reacts to selection (reactive)
const Button = observer(({ store }) => (
<button disabled={!store.selectedElementsIds.length}>
Delete ({store.selectedElementsIds.length})
</button>
));

// Business: every real mutation marks dirty (event)
store.editor?.on('object:modified', () => setDirty(true));
store.editor?.on('workarea:changed', () => setDirty(true));

Performance notes

Reactivity is great, but "you're subscribed to whatever you read" makes it easy to accidentally subscribe to something that re-renders too often.

① Break up observer into smaller components

// ❌ The whole App is observer — any field change re-diffs the whole tree
const App = observer(({ store }) => (
<>
<Header store={store} />
<Workspace store={store} />
<Footer store={store} />
</>
));

// ✅ Each child observers on its own — re-renders only when its own fields change
const Header = observer(({ store }) => <div>Zoom {store.scale}</div>);
const Footer = observer(({ store }) => <div>{store.objects.length} objects</div>);

② Throttle / debounce reaction

object:modified fires dozens of times per second during drags and typing. Throttle any heavy side effect:

reaction(
() => store.toJSON(),
(json) => fetch('/api/save', { method: 'POST', body: JSON.stringify(json) }),
{ delay: 1000 }, // only the last change in each 1s window wins
);

③ Don't read reactive fields outside an observer

// ❌ Comp isn't observer — the read isn't tracked
const Comp = ({ store }) => <div>{store.width}</div>;

// ✅ Wrap with observer
const Comp = observer(({ store }) => <div>{store.width}</div>);

④ Don't rebuild toJSON() every tick

// ❌ Serializes the whole canvas on every change
reaction(
() => store.toJSON(),
(json) => { /* heavy object construction every time */ },
);

// ✅ Track something narrower if that's all you care about
reaction(
() => store.objects.length,
(len) => console.log('count', len),
);

⑤ Clean up subscriptions on unmount

  • observer cleans up itself — nothing to do
  • reaction / autorun return a dispose function — return it from useEffect
  • store.editor.on(...) / store.on('change', ...) — call off manually, or the returned dispose

See also