Reactivity & Events
Building editors with Ydesign usually boils down to three kinds of question:
- "The canvas changed — how do I refresh my UI automatically?" → Reactivity
- "The user selected / edited / undid something — how do I catch that action?" → Events
- "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/useCallbackor 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);
});
💡
onreturnsvoid. Keep a reference to yourhandlerexternally so you can pass it tooff— same discipline as the DOMaddEventListener.
Built-in events (from the real source)
| Event | When it fires | Payload |
|---|---|---|
object:modified | Element properties changed (including lock / unlock) | { target, action } |
textbox:modified | Textbox scale / text content changed | { target, action } |
workarea:changed | Workarea size or background changed; also fires on undo/redo | { target, action } |
workarea:max-size | Canvas size hit the resize cap | { maxWidth, maxHeight } |
history:changed | History stack changed (push / undo / redo) | { type, backgroundColor?, ... } |
inpaint:activate | Entered AI eraser mode | — |
inpaint:deactivate | Exited AI eraser mode | — |
inpaint:status | AI 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" detectionstore.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
observercleans up itself — nothing to doreaction/autorunreturn adisposefunction — return it fromuseEffectstore.editor.on(...)/store.on('change', ...)— calloffmanually, or the returned dispose
See also
- 👉 Store overview — every reactive field at a glance
- 👉 Elements · listen for changes — more event subscription examples
- 👉 MobX docs —
reaction/autorun/observerdeep dive - 👉 MobX-State-Tree docs —
onSnapshot/getSnapshot