Modify the tlDraw store without firing listeners
I’ve been working with the infinite canvas framework tldraw quite a bit. It has some listeners that you can tie into so that you can run callbacks whenever the user edits, moves, or selects something on the canvas.
The problem, I found, was that when I make changes to the canvas programmatically, those listeners would also fire. Often causing infinite loops or erroneous changes.
The below article discusses how to get around that.
The problem
While tlDraw provides a listener you can tie into for responding to changes, no listener exclusively relates to changes made by the user. And while there are filters you can apply to the listener, they don’t cover that use case.
Problem code
Here’s an example of the problem using React.
function ReactComponent() {
const runMyCameraPostProcess = function(editor, entry) {
const someCoords = getRelevantCoords(entry);
editor.setCamera(someCoords);
}
const handleTldrawMount = (editor) => {
// Start listening for any change to the store
editor.store.listen( (entry) => {
runMyCameraPostProcess(editor);
})
}
return <Tldraw
onMount = {handleTldrawMount}
/>;
}
Problem Explanation
In the above code, runMyCameraPostProcess
is a function that I want to call whenever the user does something in the editor — Perhaps in the example the function is designed to move the camera to follow the user’s edits on screen.
When Tldraw is mounted, a listener is started that will fire whenever the tlDraw store changes. The problem with the above code is that the listener will fire if the user makes an edit, but also when we make an edit via code. This means that one user edit will cause the listener to fire in infinite succession.
User edit
Listener fires from user edit
- Run post process code edit
Listener fires from code edit
- Run post process code edit
Listener fires from code edit
- Run post process code edit
Listener fires from code edit
- Run post process code edit
...
etc, etc, etc. (infinite loop)
A messy manual solution
To solve the infinite loop problem, I initially, implemented a variable in my React code that I would set as true when I programmatically edited something. ie. wasChangedByCode = true
. This allowed me to check that variable whenever the listener fired and bail out if I had already responded to it.
Manual code
function ReactComponent() {
const wasChangedByCodeRef = React.useRef(false);
const runMyCameraPostProcess = function(editor, entry) {
const someCoords = getRelevantCoords(entry);
editor.setCamera(someCoords);
// Remember that this change was done by the program, not the user.
wasChangedByCodeRef.current = true;
}
const handleTldrawMount = (editor) => {
editor.store.listen( (entry) => {
// Check what caused the last change
if(wasChangedByCodeRef.current) {
wasChangedByCodeRef.current = false; // Reset for next time
return; // Bail out
} else { // Change by human
runMyCameraPostProcess(editor);
}
})
}
return <Tldraw
onMount = {handleTldrawMount}
/>;
}
Note
If you’re not used to using refs in React,wasChangedByHumanRef.current = true
is the basically the same aswasChangedByHuman = true
. The difference is that how you access the value is through acurrent
property because it is a React ref, and I’ve renamed the variable to remind myself of that.Refs are used because they persist across renders in React but don’t cause re-renders themselves.
A better approach
After asking on the tlDraw discord forums, I was made aware that there is also a method in the store object that can solve this problem.
By wrapping any changes you make to the store with the mergeRemoteChanges
method, you can make changes in a way that won’t cause the listeners to fire in the first place.
Better code
Using this method, the code becomes much simpler.
function ReactComponent() {
const runMyCameraPostProcess = function(editor, entry) {
const someCoords = getRelevantCoords(entry);
// Wrap editor changes in mergeRemoteChanges
editor.store.mergeRemoteChanges( () => {
editor.setCamera(someCoords);
}))
}
const handleTldrawMount = (editor) => {
editor.store.listen( (entry) => {
runMyCameraPostProcess(editor);
})
}
return <Tldraw
onMount = {handleTldrawMount}
/>;
}
Which one?
Using mergeRemoteChanges is the simpler solution and should be used most of the time, however, you may run into times when you want the listeners to fire regardless, and only tailor part of the the listener based on what initiated the change. In that kind of situation, you’d need to use a more manual approach.
A little information on the side
mergeRemoteChanges purpose & name
I don’t find mergeRemoteChanges to be very intuitively named. It seems to be designed to reflect a use case of running tlDraw in multiple instances (even on different devices). In this kind of situation, changes by the user can be considered local to this instance, while changes by another user are coming from a remote instance.
However, that kind of use case is likely less common compared to simply wanting to make changes via code without firing listeners — Which I expect almost every project to do.
Tweaking the mergeRemoteChanges name
While I’ve become comfortable doing the mental gymnastics of using the word remote to mean not local user initiated (But including local code), if I ever stop working with tlDraw for a year or so, or if I need other people unfamiliar with the framework to make an edit, I don’t want either of us scratching our heads at the code for any longer than necessary.
Like my own variable and method names, I prefer when a framework uses names that are very clear as to their impact or meaning and accurately generalised. With framework APIs, however, mistakes or evolutions from early in development often can’t be changed later without unnecessarily making the framework more complex or introducing breaking changes.
But as users of the framework, we can create a helper function that wraps mergeRemoteChanges so that it’s more understandable in our codebase.
I made two.
export const silentlyChangeStore = (editor: Editor, func: () => void) => {
editor.store.mergeRemoteChanges(func)
}
export const silentlyChangeStoreAsync = async (editor: Editor, func: () => void) => {
editor.store.mergeRemoteChanges(func)
}
Here’s the example from above replaced with one of these functions:
function ReactComponent() {
const runMyCameraPostProcess = function(editor, entry) {
const someCoords = getRelevantCoords(entry);
silentlyChangeStore( editor, () = {
editor.setCamera(someCoords);
});
}
const handleTldrawMount = (editor) => {
editor.store.listen( (entry) => {
runMyCameraPostProcess(editor);
})
}
return %3CTldraw
onMount = {handleTldrawMount}
/>;
}
That’s it
Thanks…
I also dissect and speculate on design and development.
Digging into subtle details and implications, and exploring broad perspectives and potential paradigm shifts.
Check out my conceptual articles on Substack or find my latest below.
You can also find me on Threads, Bluesky, Mastodon, or X for more diverse posts about ongoing projects.
Leave a Reply