javascriptreactjssocket.ioreact-hooksreact-lifecycle-hooks

React Hook useCallback not getting updated state when invoked


I am developing this application where I double click the page and create a new item. Then I notify a websockets server to broadcast this creation to all other users listening to it. On the other user end I must add this new item, so everybody gets synchronized.

I am using react hooks and this is my first app from the scratch using it. I am having several problems like this in this application, but I selected this flow to illustrate the basic problem. It must cover most issues.

When I call the useCallback to add the item on the "other user" side, the items state is old (empty). So no matter how many times I create items, I will only have one on the page, the one I just created.

This is a sample of the code:

import React, { useEffect, useState, useCallback } from 'react';
import io from 'socket.io-client';

let socket;

function App() {
  const [items, setItems] = useState({});

  const createItem = useCallback((data) => {
    const id = generateId();
    const newItem = { id, ...data };
    const newItems = { ...items, [id]: newItem };

    setItems(newItems)

    // When I create a new item, I notify the server to broadcast it
    socket.emit('itemCreated', newItem)
  }, [items, setItems])

  const doCreateItem = useCallback((item) => {
    // When someone else creates an item, it should be invoked be invoked
    const newItems = { ...items, [item.id]: item };
    setItems(newItems)
  }, [items]);

  useEffect(() => {
    // Connects to the server
    socket = io('ws://localhost:8090', {
      transports: ['websocket'],
      path: '/ws'
    });

    // Listens to event
    socket.on('createitem', (data) => {
      doCreateItem(data)
    });

    // I cannot add doCreateItem here, otherwise it will call this effect everytime doCreateItem is executed
  }, []);

  return (
    <div
      onDoubleClick={
        (e) => {
          createItem({});
        }
      }
    >

      {
        Object.values(items).map(item => {
          return (
            <div>{item.id}</div>
          )
        })
      }

    </div>
  );
}

export default App;

The doCreateItem has the items dependency. One the other hand, the useEffect dependency list doesn't contain doCreateItem mostly because it will reinvoke the useEffect callback everytime I receive a new item from the server. And also because I want this effect to be executed only once (on component mounting). I tried splitting the useEffect in two, one for the connection and another for the listeners, but the same happens.

I actually had several problems like this one. A callback is executed more than I was expecting or the state is not updated on the callback.

Could someone clarify how we should get around this problem?

Thanks in advance!

Edit

As suggested by @Ross, I changed the setItems calls to receive a callback and return the new state. That helped with the setItems dependency. But I still can't read items in the doCreateItem function. It is always empty.

Something funny that I noticed, is that if I stop using the useCallback and recreate the functions on each render, it doesn't have the updated state on that function, but it does have outside the function:

function App() {
  const [items, setItems] = useState({});
 
  console.log('Rendered again and knows the state >', items)
  const doCreatePostIt = (data) => {
    console.log('Empty >', items)
    // ...
  };
  // ...
}

Solution

  • When the next state depends on the current one, using the functional update form of useState eliminates a dependency in the dependency array and solves the "stale state" problem.

    const [items, setItems] = useState({});
    
    const createItem = useCallback((data) => {
      const id = generateId();
      const newItem = { id, ...data };
    
      setItems((currItems) => ({
        ...currItems,
        [id]: newItem,
      }))
    
      socket.emit('itemCreated', newItem)
    }, [])
    
    useEffect(() => {
      function doCreateItem(item) {
        setItems((currItems) => ({
          ...currItems,
          [item.id]: item,
        }))
      }
    
      socket = io('ws://localhost:8090', {
        transports: ['websocket'],
        path: '/ws'
      });
    
      socket.on('createitem', doCreateItem);
    
      // Return a clean up function so the component will stop listening to the
      // socket when the component is unmounted.
      return () => {
        socket.off('createItem', doCreateItem)
      }
    }, []);