Stale React State function callback issue

Hello, we ran into an issue where function callbacks passed into ReactDiagram lose the React state.

When a browser user action such as mouseDrop triggers a function callback invokation, the state within this function closure does not have any state value, everything is null.

We narrowed down our solution to manually triggering a ReactDiagram render by changing the value of the key prop to random values just to trigger the rendering.

Even though this works for us at the moment, we’re skeptical because we might run into other issues down the line, or maybe not, we don’t know yet.

Our question is: does this solution force a complete render of the browser DOM or does ReactDiagram know it is a partial render of just the key prop and that’s this? is there a more granular or proper approach to solve the stale state function closure issue?

Thank you!

This is the specific code solution:

useEffect(() => {
    if (templateGroupData || selectedTemplateData) {
        const randomKey = Math.random().toString(36);
        setDiagramKey(randomKey);
    }
}, [selectedTemplateData, templateGroupData, setDiagramKey]);

<ReactDiagram key={diagramKey} />

Here’s our entire code of the React component and our custom hook function callbacks.

import { useEffect, useRef, useState } from 'react';
import { ReactDiagram, ReactPalette } from 'gojs-react';
import { useAppDispatch, useAppSelector } from '../../../../hooks/hooks';
import useGoJsInitDiagram from '../hooks/useGoJsInitDiagram';
import useGoJsInitPalette from '../hooks/useGoJsInitPalette';
import { setPaletteData, setDocletData } from '../../../../store/slices';
import { useDocletsQuery } from '../../../../store/api/generated';
import transformDoclets from '../hooks/transformDoclets';
import useDiagramEventHandlers from './hooks/useDiagramEventHandlers';
import './GoJsDiagram.css';

const GoJsDiagram = () => {
    const appState: any = useAppSelector((state) => state);
    const { doclets, selectedDoclets } = appState;
    const dispatch = useAppDispatch();
    const diagramRef = useRef<ReactDiagram>(null);
    const paletteRef = useRef<ReactPalette>(null);

    const [diagramKey, setDiagramKey] = useState<string>('initialKey');
    const { diagramData, paletteData, allNodes, templateGroupData } = doclets;
    const { selectedNodeData, selectedTemplateData } = selectedDoclets;
    const diagramEventHandlers = useDiagramEventHandlers();
    const { clearPalette, handleModelChange } = diagramEventHandlers;

    // Hook is auto-generated by RTK Query from the doclets.grapql file
    // TODO: Look at transfromResponse to see if we can get the data in the format we want
    const { data, isLoading } = useDocletsQuery();

    // Transform data once it's loaded into GoJs format - transformation function is in doclets.slice.ts - and update diagram
    // I updated the main diagram to show the principle - (need more work to update the palette given teh way AS has configured it)
    useEffect(() => {
        if (data && diagramRef.current) {
            const model = transformDoclets(data.doclets);
            dispatch(setDocletData(model));
        }
    }, [data, diagramRef, dispatch, setPaletteData]);

    useEffect(() => {
        if (templateGroupData || selectedTemplateData) {
            const randomKey = Math.random().toString(36);
            setDiagramKey(randomKey);
        }
    }, [selectedTemplateData, templateGroupData, setDiagramKey]);

    useEffect(() => {
        if (selectedTemplateData) {
            if (!selectedNodeData && paletteData) {
                clearPalette();
            }
            if (selectedNodeData && !paletteData) {
                dispatch(setPaletteData({ nodeDataArray: allNodes }));
            }
        }

        if (selectedNodeData) {
            if (selectedTemplateData) {
            }
        }
    }, [
        dispatch,
        setPaletteData,
        paletteData,
        diagramData,
        allNodes,
        selectedNodeData,
        selectedTemplateData,
    ]);

    return (
        <div className="GoJsDiagram">
            <ReactPalette
                ref={paletteRef}
                initPalette={useGoJsInitPalette}
                divClassName="goJsPalette"
                nodeDataArray={
                    paletteData?.nodeDataArray ? paletteData?.nodeDataArray : []
                }
            />
            <ReactDiagram
                key={diagramKey}
                ref={diagramRef}
                initDiagram={useGoJsInitDiagram(diagramEventHandlers)}
                divClassName="goJsDiagram"
                nodeDataArray={
                    diagramData?.nodeDataArray ? diagramData?.nodeDataArray : []
                }
                linkDataArray={diagramData?.linkDataArray}
                onModelChange={handleModelChange}
            />
        </div>
    );
};

export default GoJsDiagram;

import { useAppDispatch, useAppSelector } from '../../../../../hooks/hooks';
import {
    addTemplateToDiagram,
    setPaletteData,
    setSelectedNode,
} from '../../../../../store/slices';

const useDiagramEventHandlers = () => {
    const dispatch = useAppDispatch();
    const appState: any = useAppSelector((state) => state);
    const { doclets, selectedDoclets } = appState;
    const { paletteData, templateGroupData } = doclets;
    const { selectedTemplateData } = selectedDoclets;

    function docletDropped(ev: any, Node: any, go: any) {
        if (selectedTemplateData) {
            if (ev.diagram.currentTool instanceof go.DraggingTool) {
                // an internal drag-and-drop
            } else {
                // an external drag-and-drop from another Diagram
            }
        }
    }

    function externalObjectsDropped(diagramInstance: any) {
        if (!selectedTemplateData) {
            const { templateIndex } = diagramInstance.selection.first().data;
            diagramInstance.commandHandler.deleteSelection();
            const selTemplate = templateGroupData![templateIndex];
            dispatch(addTemplateToDiagram(selTemplate));
        }
    }

    function selectionChanged(node: any) {
        switch (node.qb.color) {
            case 'lightblue':
                if (node.isSelected) {
                    dispatch(setSelectedNode(node.qb));
                } else {
                    dispatch(setSelectedNode(null));
                }
                break;

            default:
                dispatch(setSelectedNode(null));
                break;
        }
    }

    function handleModelChange(changes: Object) {
        if (typeof changes === 'object') {
            console.table(changes);
        } else {
            console.log(changes);
        }
    }

    function clearPalette() {
        if (paletteData) {
            dispatch(setPaletteData(null));
        }
    }

    return {
        clearPalette,
        docletDropped,
        handleModelChange,
        externalObjectsDropped,
        selectionChanged,
    };
};

export default useDiagramEventHandlers;

initDiagram is only called once, when the ReactDiagram mounts. Maybe you want to conditionally render ReactDiagram only after all the listeners are ready to be added.

If you’re adding DiagramEvent handlers, you may want to do something like gojs-react-basic. It passes a handleDiagramEvent function to its DiagramWrapper component, which adds and removes the listeners from the diagram. This is how "SelectionChanged" is handled.

If you’re adding GraphObject handlers, like mouseDrop, you could bind them to some function in the modelData. When that function changes in the modelData state, the GraphObject will reflect the change due to the ofModel binding.