React Native Native Module Event Emission Issues with iOS

Setup

I have a native module that extends RCTEventEmitter and also implements a TurboModule spec:

RCTNativeLocalStorage.h:

#import <NativeLocalStorageSpec/NativeLocalStorageSpec.h>
#import <React/RCTEventEmitter.h>

NS_ASSUME_NONNULL_BEGIN
@interface RCTNativeLocalStorage : RCTEventEmitter <NativeLocalStorageSpec>
@end
NS_ASSUME_NONNULL_END

RCTNativeLocalStorage.m (partial):

#import "RCTNativeLocalStorage.h"
#import <CoreBluetooth/CoreBluetooth.h>
#import <NativeLocalStorageSpec/NativeLocalStorageSpec.h>

using namespace facebook;

@interface RCTNativeLocalStorage () <CBCentralManagerDelegate, CBPeripheralDelegate>
@property (strong, nonatomic) NSUserDefaults *localStorage;
@property (strong, nonatomic) CBCentralManager *centralManager;
@property (strong, nonatomic) NSMutableArray<NSDictionary *> *discoveredDevices;
@property (nonatomic, assign) BOOL hasListeners;
@end

@implementation RCTNativeLocalStorage

// Register the module
RCT_EXPORT_MODULE(NativeLocalStorage)

// These methods are required for NativeEventEmitter to work properly
RCT_EXPORT_METHOD(addListener:(NSString *)eventName)
{
  NSLog(@"🎧 addListener called for: %@", eventName);
}

RCT_EXPORT_METHOD(removeListeners:(double)count)
{
  NSLog(@"πŸ”• removeListeners called: %f", count);
}

// Define supported events
- (NSArray<NSString *> *)supportedEvents {
  return @[
    @"BluetoothDeviceFound",
  ];
}

// Event listener tracking
- (void)startObserving {
  NSLog(@"βœ… startObserving called - events will be emitted");
  self.hasListeners = YES;
}

- (void)stopObserving {
  NSLog(@"⚠️ stopObserving called - events will not be emitted");
  self.hasListeners = NO;
}

- (instancetype)init {
  if (self = [super init]) {
    _localStorage = [[NSUserDefaults alloc] initWithSuiteName:@"local-storage"];
    _centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil];
    _discoveredDevices = [NSMutableArray new];
    _hasListeners = NO;
  }
  return self;
}

+ (BOOL)requiresMainQueueSetup {
  return NO;
}

// TurboModule implementation
- (std::shared_ptr<react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<react::NativeLocalStorageSpecJSI>(params);
}

// MARK: - TurboModule Methods

- (NSString * _Nullable)getItem:(NSString *)key {
  return [self.localStorage stringForKey:key];
}

- (void)setItem:(NSString *)value key:(NSString *)key {
  [self.localStorage setObject:value forKey:key];
}

- (void)removeItem:(NSString *)key {
  [self.localStorage removeObjectForKey:key];
}

- (void)clear {
  NSDictionary *allItems = [self.localStorage dictionaryRepresentation];
  for (NSString *key in allItems.allKeys) {
    [self.localStorage removeObjectForKey:key];
  }
}

// Export the startScan method to make it available to JavaScript
RCT_EXPORT_METHOD(startScan) {
  NSLog(@"βœ… startScan triggered from JavaScript");
  
  if (_centralManager.state != CBManagerStatePoweredOn) {
    NSLog(@"❌ Bluetooth not powered on");
    return;
  }
  
  // Show an alert to verify the method was called
  dispatch_async(dispatch_get_main_queue(), ^{
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Scan"
                                                                   message:@"startScan called!"
                                                            preferredStyle:UIAlertControllerStyleAlert];
    UIAlertAction *ok = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil];
    [alert addAction:ok];
    UIViewController *root = UIApplication.sharedApplication.keyWindow.rootViewController;
    [root presentViewController:alert animated:YES completion:nil];
  });
  
  [_discoveredDevices removeAllObjects];
  [_centralManager scanForPeripheralsWithServices:nil options:nil];
}

// Central Manager Delegates
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
  switch (central.state) {
    case CBManagerStatePoweredOn:
      NSLog(@"βœ… Bluetooth is powered on.");
      break;
    case CBManagerStatePoweredOff:
      NSLog(@"❌ Bluetooth is powered off.");
      break;
    default:
      NSLog(@"⚠️ Bluetooth state changed: %ld", (long)central.state);
      break;
  }
}

- (void)centralManager:(CBCentralManager *)central
 didDiscoverPeripheral:(CBPeripheral *)peripheral
     advertisementData:(NSDictionary<NSString *, id> *)advertisementData
                  RSSI:(NSNumber *)RSSI {

  NSString *deviceName = peripheral.name ?: @"Unknown";
  NSString *deviceId = peripheral.identifier.UUIDString;

  NSDictionary *deviceInfo = @{
    @"name": deviceName,
    @"id": deviceId
  };

  BOOL alreadyExists = NO;
  for (NSDictionary *existingDevice in _discoveredDevices) {
    if ([existingDevice[@"id"] isEqualToString:deviceId]) {
      alreadyExists = YES;
      break;
    }
  }
  
  if (!alreadyExists) {
    [_discoveredDevices addObject:deviceInfo];
    NSLog(@"βœ… Device discovered: %@", deviceInfo);
    
    // Send event directly on the main thread
    dispatch_async(dispatch_get_main_queue(), ^{
      // The hasListeners check is important to avoid the warning
      if (self.hasListeners) {
        NSLog(@"πŸš€ Sending BluetoothDeviceFound event");
        [self sendEventWithName:@"BluetoothDeviceFound" body:deviceInfo];
      } else {
        NSLog(@"⚠️ No listeners registered for BluetoothDeviceFound event");
      }
    });
  }
}

@end

Javascript Code:

const { NativeLocalStorage } = NativeModules;
const eventEmitter = new NativeEventEmitter(NativeLocalStorage);

useEffect(() => {
  console.log('Setting up event listener...');
  
  const subscription = eventEmitter.addListener(
    'BluetoothDeviceFound',
    (deviceInfo) => {
      console.log('Device found:', deviceInfo);
      // Update state...
    }
  );
  
  console.log('Starting scan...');
  NativeLocalStorage.startScan();
  
  return () => subscription.remove();
}, []);

Console output after i trigger start scan

βœ… Bluetooth is powered on.
'`new NativeEventEmitter()` was called with a non-null argument without the required `addListener` method.', { [Component Stack] name: 'Component Stack' }
'`new NativeEventEmitter()` was called with a non-null argument without the required `removeListeners` method.', { [Component Stack] name: 'Component Stack' }
βœ… startScan triggered from JavaScript
βœ… Device discovered: {
    id = "E2DEF552-4C7E-FA6F-1CC3-3F6B0DE3CC31";
    name = Unknown;
}
⚠️ No listeners registered for BluetoothDeviceFound event
⚠️ No listeners registered for BluetoothDeviceFound event
⚠️ No listeners registered for BluetoothDeviceFound event

The Problem

Unable to send events from iOS native code. The same Code works with android and I am able to send data to the event listeners.

What do you think I am missing here?

Official docs link: iOS Native Modules Β· React Native

The issue you’re encountering with iOS and the React Native event emitter typically arises because the listener functions (addListener and removeListeners) aren’t being set up properly in your native module. To fix this, ensure that your RCTEventEmitter subclass properly implements both methods.

Here’s a corrected version of your code with the necessary fixes:

  1. Ensure that addListener and removeListeners are implemented correctly.
  2. Check if hasListeners is true before emitting events.

Corrected iOS Native Code:

objective

#import "RCTNativeLocalStorage.h"
#import <CoreBluetooth/CoreBluetooth.h>
#import <NativeLocalStorageSpec/NativeLocalStorageSpec.h>

using namespace facebook;

@interface RCTNativeLocalStorage () <CBCentralManagerDelegate, CBPeripheralDelegate>
@property (strong, nonatomic) NSUserDefaults *localStorage;
@property (strong, nonatomic) CBCentralManager *centralManager;
@property (strong, nonatomic) NSMutableArray<NSDictionary *> *discoveredDevices;
@property (nonatomic, assign) BOOL hasListeners;
@end

@implementation RCTNativeLocalStorage

RCT_EXPORT_MODULE(NativeLocalStorage)

- (NSArray<NSString *> *)supportedEvents {
  return @[@"BluetoothDeviceFound"];
}

// Implement the required addListener and removeListeners methods
RCT_EXPORT_METHOD(addListener:(NSString *)eventName) {
  self.hasListeners = YES;
  NSLog(@"🎧 Listener added for event: %@", eventName);
}

RCT_EXPORT_METHOD(removeListeners:(double)count) {
  self.hasListeners = NO;
  NSLog(@"πŸ”• Listener removed. Remaining listeners: %f", count);
}

- (void)startObserving {
  NSLog(@"βœ… startObserving called");
  self.hasListeners = YES;
}

- (void)stopObserving {
  NSLog(@"⚠️ stopObserving called");
  self.hasListeners = NO;
}

- (instancetype)init {
  if (self = [super init]) {
    _localStorage = [[NSUserDefaults alloc] initWithSuiteName:@"local-storage"];
    _centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil];
    _discoveredDevices = [NSMutableArray new];
    _hasListeners = NO;
  }
  return self;
}

+ (BOOL)requiresMainQueueSetup {
  return NO;
}

// TurboModule implementation
- (std::shared_ptr<react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<react::NativeLocalStorageSpecJSI>(params);
}

// Export the startScan method to make it available to JavaScript
RCT_EXPORT_METHOD(startScan) {
  NSLog(@"βœ… startScan triggered from JavaScript");
  
  if (_centralManager.state != CBManagerStatePoweredOn) {
    NSLog(@"❌ Bluetooth not powered on");
    return;
  }

  [_discoveredDevices removeAllObjects];
  [_centralManager scanForPeripheralsWithServices:nil options:nil];
}

// Central Manager Delegates
- (void)centralManagerDidUpdateState:(CBCentralManager *)central {
  switch (central.state) {
    case CBManagerStatePoweredOn:
      NSLog(@"βœ… Bluetooth is powered on.");
      break;
    case CBManagerStatePoweredOff:
      NSLog(@"❌ Bluetooth is powered off.");
      break;
    default:
      NSLog(@"⚠️ Bluetooth state changed: %ld", (long)central.state);
      break;
  }
}

- (void)centralManager:(CBCentralManager *)central
 didDiscoverPeripheral:(CBPeripheral *)peripheral
     advertisementData:(NSDictionary<NSString *, id> *)advertisementData
                  RSSI:(NSNumber *)RSSI {

  NSString *deviceName = peripheral.name ?: @"Unknown";
  NSString *deviceId = peripheral.identifier.UUIDString;

  NSDictionary *deviceInfo = @{ @"name": deviceName, @"id": deviceId };

  BOOL alreadyExists = NO;
  for (NSDictionary *existingDevice in _discoveredDevices) {
    if ([existingDevice[@"id"] isEqualToString:deviceId]) {
      alreadyExists = YES;
      break;
    }
  }

  if (!alreadyExists) {
    [_discoveredDevices addObject:deviceInfo];
    NSLog(@"βœ… Device discovered: %@", deviceInfo);

    // Send event directly on the main thread
    dispatch_async(dispatch_get_main_queue(), ^{
      if (self.hasListeners) {
        [self sendEventWithName:@"BluetoothDeviceFound" body:deviceInfo];
        NSLog(@"πŸš€ Sending BluetoothDeviceFound event");
      } else {
        NSLog(@"⚠️ No listeners registered for BluetoothDeviceFound event");
      }
    });
  }
}

@end