Search code examples
reactjssignalr

Why does SignalR not see current state of data in React app


I have searched many a tutorial and have searched google for hours. I can't find anything to answer why SignalR callback isn't seeing the current state of my data but React does just fine.

First, my code (a simplified version anyways):

React component:

function example() {
    const [data, setData] = useState<DataModel[]>([]);

    useEffect(() => {
        loadSignalR();
        loadData();
    }, []);

    async function loadData() {
        const serverData= await getAllData();
        setData(serverData);
    }

    function addData(addedItem: DataModel) {
        setData([...data, addedItem]);
    }

    function loadSignalR() {
        signalRService.startConnection(`${BASE_PATH}/dataHub`);
        signalRService.onDataAdded(addData);
    }

    return (
        <div className="ps-3" style={{ minWidth: '50vw' }}>
            <h2 className="w-100 text-center">Data</h2>
            
            <DataCarouselContainer
                data={data}
            />
        </div>
    );
}

and the signalRService:

const signalRService = {
    connection: null,

    startConnection: hubUrl => {
        signalRService.connection = new HubConnectionBuilder()
            .withUrl(hubUrl)
            .build();

        signalRService.connection
            .start()
            .then(() => signalRService.addToGroup('QT'))
            .then(() => {
                console.log('SignalR connection established.');
            })
            .catch(err => {
                console.error('Error starting SignalR connection:', err);
            });
    },

    onDataAdded: callback => {
        signalRService.connection.on('DataAdded', serverData=> {
            callback(serverData);
        });
    },

    addToGroup: groupName => {
        signalRService.connection.invoke('AddToGroup', groupName).catch(err => {
            console.error('Error adding to group:', err);
        });
    },
};

export default signalRService;

The backend is working fine. It sends events and the front end receives them. Everything seems to work but when addData is called it sees data as an empty array. My screen shows it's not really empty as I can see 4 items listed on the screen.

So what am I doing wrong? Why does SignalR only ever see the state of data as an empty array?

The goodish/terrible news is that in my googling I found a workaround from others with the same issue. If I create a second, throwaway, variable, say:

const [newData, setNewData] = useState<TaskModel[]>([]);

and then add a new useEffect, like:


    useEffect(() => {
        setData([...data, ...newData]);
    }, [newData]);

It works. newData becomes throwaway dataand react sees data as the current state. So in the useEffect I can update the data and have it shown.

Problem is, it's messy and if I want to do more events, like remove data, I need to add more throw away code and more useEffects. It can easily get out of hand and I hate the idea of it. I just want SignalR to correctly see the current status without having to create a new connection every time data changes.

I tried separating the code. I tried setting the data directly, indirectly, and even with an events array.

I tried using a single useEffect to no avail (infinite loop created).

Nothing seems to work expect throwaway code.


Solution

  • The JavaScript closure is the cause of the problem you are having. It always sees an empty array because when you call addData from the SignalR callback, it records the data state at the moment the callback was registered.

    Here's a more efficient way to handle the issue without creating a second state:

    • Using a Functional Update: The most recent state can be used by utilizing react state updates along with a functional update.
    • Ref and State Combination: Using a ref to keep track of the current state without requiring re-renders can also be beneficial.

    Update your component to use the ref:

    function example() {
    const [data, setData] = useState<DataModel[]>([]);
    const dataRef = useRef<DataModel[]>([]);
    
    useEffect(() => {
        loadSignalR();
        loadData();
    }, []);
    
    async function loadData() {
        const serverData = await getAllData();
        setData(serverData);
        dataRef.current = serverData;
    }
    
    function addData(addedItem: DataModel) {
        setData(prevData => {
            const updatedData = [...prevData, addedItem];
            dataRef.current = updatedData;
            return updatedData;
        });
    }
    
    function loadSignalR() {
        signalRService.startConnection(`${BASE_PATH}/dataHub`);
        signalRService.onDataAdded(addData);
    }
    
    return (
        <div className="ps-3" style={{ minWidth: '50vw' }}>
            <h2 className="w-100 text-center">Data</h2>
            
            <DataCarouselContainer data={data} />
        </div>
    );}
    

    Using the functional update method or combining ref and state ensures that your SignalR callback always sees the most recent state. This approach should eliminate the need for throwaway code and extra useEffect hooks.