Updating useState array from callback

Viewed 65

I am building a React Native (Expo) app that scans for Bluetooth devices. The Bluetooth API exposes a callback for when devices are detected, which I use to put non-duplicate devices into an array:

const DeviceListView = () => {
  const [deviceList, setDeviceList] = useState([]);
  
  const startScanning = () => {
    manager.startDeviceScan(null, null, (error, device) => {
        // Add to device list if not already in list
        if(!deviceList.some(d => d.device.id == device.id)){
          console.log(`Adding ${device.id} to list`);
          const newDevice = {
            device: device,
            ...etc...
          };
          setDeviceList(old => [...old, newDevice]);
        }
    });
  }

  // map deviceList to components
  componentList = deviceList.map(...);

  return <View> {componentList} </View>
}

The problem is that the callback is called many many times faster than setDeviceList updates, so the duplicate checking doesn't work (if I log deviceList, it's just empty).

If I use an additional, separate regular (non-useState) array, the duplicate checking works, but the state doesn't update consistently:

const DeviceListView = () => {
  const [deviceList, setDeviceList] = useState([]);
  var deviceList2 = [];
  
  const startScanning = () => {
    manager.startDeviceScan(null, null, (error, device) => {
        // Add to device list if not already in list
        if(!deviceList2.some(d => d.device.id == device.id)){
          console.log(`Adding ${device.id} to list`);
          const newDevice = {
            device: device,
            ...etc...
          };
          deviceList2.push(newDevice);
          setDeviceList(old => [...old, newDevice]);
        }
    });
  }

  // map deviceList to components
  componentList = deviceList.map(...);

  return <View> {componentList} </View>
}

This code almost works, but the deviceList state doesn't update correctly: it shows the first couple of devices but then doesn't update again unless some other component causes a re-render.

What do I need to do to make this work as expected?

2 Answers

I would suggest wrap your duplicate check within the state set function itself, and then return the same device list if no new devices have been found. This offloads race condition handling to the underlying react implementation itself, which I've found to be good enough for most cases.

Thus it would look something like this:

const DeviceListView = () => {
  const [deviceList, setDeviceList] = useState([]);
  
  const startScanning = () => {
    manager.startDeviceScan(null, null, (error, device) => {
        // Add to device list if not already in list
        setDeviceList(old => {
          if(!old.some(d => d.device.id == device.id)){
            console.log(`Adding ${device.id} to list`);
            const newDevice = {
              device: device,
              // ...etc...
            };
            return [...old, newDevice]
          }

          return old
        });
    });
  }

  // map deviceList to components
  componentList = deviceList.map(...);

  return <View> {componentList} </View>
}

Since old is unchanged if no new unique devices are found it will also skip next re-render according to the docs ( which is a neat optimisation :) )

This is the preferred way to implement state updates that are dependant on previous state according to the docs https://reactjs.org/docs/hooks-reference.html#functional-updates

convert your callback to promise so that until you get completed device list, checkout below code (PS. not tested, please change as you need)

  const [deviceList, setDeviceList] = useState([]);
  const [scanning, setScanning] = useState(false);

  useEffect(() => {
    if(scanning) {
      setDeviceList([]);
      startScanning();
    }
  }, [scanning]);

  const subscription = manager.onStateChange(state => {
    if (state === "PoweredOn" && scanning === false) {
      setCanScan(true);
      subscription.remove();
    }
  }, true);

  const fetchScannedDevices = () => {
    return new Promise((resolve, reject) => {
      manager.startDeviceScan(null, null, (error, device) => {
        // Add to device list if not already in list
        if (!deviceList.some(d => d.device.id == device.id)) {
          console.log(`Adding ${device.id} to list`);
          const newDevice = {
            device: device,
            // ...etc...
          };
          resolve(newDevice);
        }
        if (error) {
          reject({});
        }
      });
    });
  };

  const startScanning = async () => {
    try {
      const newDevice = await fetchScannedDevices();
      setDeviceList(old => [...old, newDevice]);
    } catch (e) {
      //
    }
  };

  const handleScan = () => {
    setScanning(true);
  };

  // map deviceList to components
  componentList = deviceList.map(() => {});

  return (
    <View>
      <Button
        onPress={() => handleScan()}>
        Scan
      </Button>
      <View>{componentList}</View>
    </View>
  );
};
Related