Reactivity and Events
How to observe and react to changes in Polotno store using MobX and event listeners
Polotno's state is an mobx-state-tree store, so every change is observable through MobX.
In React, you get automatic UI updates by wrapping your components in observer()
:
import { observer } from 'mobx-react-lite';
// the component will be automatically updated when number of children is changed
export const App = observer(({ store }) => (
<div>
<p>Elements on the current page: {store.activePage?.children.length}</p>
</div>
));
You can also use any data from the store as dependency to react hooks:
const App = observer(({ store }) => {
React.useEffect(() => {
console.log('width of the store is changed');
}, [store.width]);
return <></>;
})
React hooks + MobX reactions
If you want logic that runs outside the render cycle, you can combine useEffect
with mobx.reaction
(or autorun
):
import { reaction } from 'mobx';
import { useEffect } from 'react';
export function useElementCounter(store) {
useEffect(() => {
// fires every time the number of elements on the active page changes
const dispose = reaction(
() => store.activePage?.children.length,
(length) => {
console.log('Element count changed:', length);
}
);
return dispose; // clean-up on unmount
}, [store]); // deps
}
Under the hood, MobX tracks exactly what you read inside the first function and re-runs the second function only when that value changes—no manual listeners, no polling.
Events
store.on('change', handler)
is the only event we fire. It triggers on any mutation: add, remove, move, resize, undo, redo, you name it.
const unsubscribe = store.on('change', () => {
console.log('Something in the store changed');
});
Detecting specific actions
Because every action funnels through the same event, you filter manually.
Example: Simple tracking of adding/removing elements
let prevCount = store.activePage?.children.length ?? 0;
const unsub = store.on('change', () => {
const newCount = store.activePage?.children.length ?? 0;
if (newCount > prevCount) {
console.log('Element added');
} else if (newCount < prevCount) {
console.log('Element removed');
}
prevCount = newCount;
});
Example: deeper tracking of adding/removing elements
If you need more gradual control, you can save more references to previous data to have a deeper diff.
let lastIds = {};
store.on('change', () => {
const newIds = {};
store.find(item => {
newIds[item.id] = item;
})
for (const id in lastIds) {
const deleted = !newIds[id];
if (deleted) {
console.log(id, 'deleted');
}
}
for (const id in newIds) {
const added = !lastIds[id];
if (added) {
console.log(id, 'added');
}
}
lastIds = newIds;
});
Example: Changes on one element
import { reaction } from 'mobx';
function watchElement(element) {
return reaction(
() => ({
x: element.x,
y: element.y,
width: element.width,
height: element.height,
rotation: element.rotation,
}),
(coords) => {
console.log('Element changed', coords);
}
);
}
// later
const dispose = watchElement(store.activePage.children[0]);
Example: Page-level changes (title, background, etc.)
reaction(
() => ({
title: store.activePage?.name,
bg: store.activePage?.background,
}),
(data) => console.log('Page updated', data)
);
Performance
change
event can fire dozens of times per second during drag/resize. Avoid heavy work inside the handler - wrap it in throttle, or debounce.
// define function to save design to backend
const saveDesign = async () => {
// export the design
const json = store.toJSON();
// save it to the backend
await fetch('https://example.com/designs', {
method: 'POST',
body: JSON.stringify(json),
});
}
// write a function for throttle saving
// it will call save no more then 1 time per second
let timeout = null;
const requestSave = () => {
// if save is already requested - do nothing
if (timeout) {
return;
}
// schedule saving to the backend
timeout = setTimeout(() => {
// reset timeout
timeout = null;
saveDesign();
}, 1000);
};
// request saving operation on any changes
store.on('change', requestSave);