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 as wasChangedByHuman = true. The difference is that how you access the value is through a current 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.

My latest articles

Focal point blocking for XR media

Planning out a linear VR experience requires thinking about where the viewers attention might be. Thinking about the focal points…

Designing immersive experiences

In traditional cinema, TV, or even the more modern phone screen, there’s limited screen real-estate. But removing that limitation creates a design problem…

The future is not prompt engineered

Let’s not pretend the importance of prompt engineering is ubiquitous. The most prevalent power of generative AI is in the way it adapts to us, not the other way around…

Author:

Date:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.