Search code examples
javascriptreactjsreact-nativesocketmobile

State array not updating properly in React Native with useState


I am working on the react native sdk for socket mobile and for some reason I am unable to reliably set state with useState in my react native application. The state change happens in a callback that I pass to Socket Mobile's capture library. Sample app can be found here (for some reason hyperlinks aren't working).

https://github.com/SocketMobile/singleentry-rn/blob/main/App.js

The callback, onCaptureEvent, listens for various capture event types. Based on these event types, we manage the state accordingly. One way we do that is when there is a DeviceArrival event (when a scanner connects to our iPad/Android Tablet), we then open the device so we can see it's properties, change those properties, etc.

We also try to update a list of devices that come in. So if a device "arrives", we add it to a list and then update the state like so.

 setDevices(prevDevices => {
                prevDevices = prevDevices || [];
                prevDevices.push({
                  guid,
                  name,
                  handle: newDevice.clientOrDeviceHandle,
                  device: newDevice,
                });
                return [...prevDevices];
              });

This works for the first time, but then it doesn't work properly and the remove device function we have is unable to find the right device to remove (because either an old device is left behind or there is no devices in the list at all).

I have also tried doing it this way...

setDeviceList((prevDevices) => [...prevDevices, newDevice]); 

This way we're not mutating the state value directly. However, when I do it this way, it doesn't work at all. When I try to find the device in the device list to remove it, it shows an error saying it cannot be found.

How or why am I unable to consistently update the list? I've looked at a number of tuts and it seems like the second way should work. I am assuming it has something to do with the fact that it is a callback function but we're not sure of a way around it other than this way.

We have also tried using useCallback for onCaptureEvent as well as making it asynchronous so we can use await in the function. Some state items do update accordingly (we have a state value status in the example) but maybe since it's a string it's easier to detect/persist the state change?

Below is the portion of the onCaptureEvent function that we think is the culprit.

const onCaptureEvent = useCallback(
    (e, handle) => {
      if (!e) {
        return;
      }
      myLogger.log(`onCaptureEvent from ${handle}: `, e);
      switch (e.id) {
        case CaptureEventIds.DeviceArrival:
          const newDevice = new CaptureRn();
          const {guid, name} = e.value;
          newDevice
            .openDevice(guid, capture)
            .then(result => {
              myLogger.log('opening a device returns: ', result);
              setStatus(`result of opening ${e.value.name} : ${result}`);
              setDevices(prevDevices => {
                prevDevices = prevDevices || [];
                prevDevices.push({
                  guid,
                  name,
                  handle: newDevice.clientOrDeviceHandle,
                  device: newDevice,
                });
                return [...prevDevices];
              });
            })
            .catch(err => {
              myLogger.log(err);
              setStatus(`error opening a device: ${err}`);
            });
          break;

    ...

}

I am using "react": "17.0.2" and "react-native": "0.68.2"

UPDATE 07/11/23

I discovered that when I console.log the devices just before the return statement of the component's UI, the device list is reflected correctly. I tested this with a useEffect that gets triggered whenever devices gets updated. See below.

useEffect(() => {
    console.log('USE EFFECT: ', devices);
  }, [devices]);

This ALSO logs the updated device list. It seems the only place where I am unable to keep track of the updated list is in the onCaptureEvent function. I'm guessing this is because onCaptureEvent is a callback that is not directly invoked by the RN component. Any ideas around this? I've used helper functions within the component outside of the onCaptureEvent method but it still doesn't seem to work--or at least onCaptureEvent is unable to find the updated state value.

Is there a way to ensure


Solution

  • I think I figured out was wrong.

    I am planning on writing a blog post about it to be more in depth but basically the state WAS updating correctly and within the context of the App component was up to date and accessible.

    The problem I think lies in that fact onCaptureEvent is NOT called or referenced directly by anything in the App component. Rather, it is invoked as a callback, almost as a side effect, of a third party library (in this case the CaptureSDK).

    Since it is passed to the CaptureSDK and invoked because of events that occur in the SDK, more complex data structures (Arrays and Objects) are harder to persist consistently.

    status was accurately reflected because it was a string. As were other state values that were integers, booleans, etc. These are much easier data types to detect differences in.

    An array or object, however, is a bit more difficult for functional components to immediately detect/register. For instance, if you updated an array, set the state using the new array, then immediately tried to log myArray, you might not see the reflected values.

    So not only were we trying to access state in a callback that has a different invocation context but we are also SETTING the state within this different context. I think this combination allowed for the appearance of state not being updated or accessible.

    The Solution

    To remedy this, I found this question on SO where the accepted answer made use of the useRef hook. For example, I could use useState to initialize and later in the code set the value for devices. I could also (after initializing with useState) create a reference instance called stateRef where I can store the devices reference.

    const App = () =>{
      const [devices, setDevices] = useState([])
      const stateRef = useRef();
    
      stateRef.current = devices
    
                   ...
    
    } 
    

    Then in onCaptureEvent I can set the state as usual, but when I want to reference the latest devices, I can use stateRef.current. I can use this list to find the removeDevice, remove it from the list and then set the state-and properly determine which device was removed.

    case CaptureEventIds.DeviceRemoval:
       let devs = [...stateRef.current];
       let index = devs.findIndex((d) => {
            return d.guid === e.value.guid;
       });
    
       if (index < 0) {
          myLogger.error(`no matching devices found for ${e.value.name}`);
           return;
       }
    
       let removeDevice = devs[index];
       devs = devs.splice(index, 1);
       setDevices(devs);
       break;