After struggling with unreliable keyboard event handling in React Native macOS, I discovered the solution: handle key events at the native macOS level instead of trying to make JavaScript event handling work reliably.
The Problem
Standard React Native keyboard handling approaches often fail on macOS:
onKeyDown
props don’t capture events consistentlydocument.addEventListener
doesn’t exist in React Native- Focus management interferes with text editing
- Chirping sounds when events aren’t properly consumed
- Events get lost when focus changes between UI elements
The Solution: Native + React Native Bridge
The key insight is to handle keyboard events at the native macOS level using Objective-C, then bridge them to React Native when needed.
Step 1: Native Keyboard Handling (AppDelegate.mm)
Add native keyboard monitoring to your AppDelegate.mm
:
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTBridge.h>
@implementation AppDelegate
- (void)applicationDidFinishLaunching:(NSNotification *)notification
{
self.moduleName = @"YourAppName";
self.initialProps = @{};
[super applicationDidFinishLaunching:notification];
// Set up native keyboard handling
[self setupNativeKeyboardHandling];
}
- (void)setupNativeKeyboardHandling
{
// Add local monitor for key events (equivalent to SwiftUI's onKeyPress)
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^NSEvent * _Nullable(NSEvent * _Nonnull event) {
NSLog(@"🔑 NATIVE: Key event - keyCode: %d, characters: %@", (int)event.keyCode, event.characters);
// Handle Escape key (keyCode 53)
if (event.keyCode == 53) {
NSLog(@"🔑 NATIVE: Escape key detected, sending to React Native");
[self sendEscapeKeyToReactNative];
// Return nil to consume the event (equivalent to SwiftUI's .handled)
return nil;
}
// Return the event for other keys to continue normal processing
return event;
}];
NSLog(@"🔑 NATIVE: Native keyboard handling setup complete");
}
- (void)sendEscapeKeyToReactNative
{
// Send event to React Native via DeviceEventEmitter
[[NSNotificationCenter defaultCenter] postNotificationName:@"NativeEscapeKeyPressed" object:nil];
if (self.bridge) {
[self.bridge.eventDispatcher sendAppEventWithName:@"NativeEscapeKeyPressed" body:@{}];
}
}
@end
Step 2: React Native Event Listener (App.tsx)
Listen for native events in your React Native app:
import React from 'react';
import { Platform, View, DeviceEventEmitter } from 'react-native';
const GlobalKeyboardWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isVisible, closePanel } = useTaskDetailPanel();
// Listen for native keyboard events from AppDelegate
React.useEffect(() => {
if (Platform.OS !== 'macos') return;
console.log('🔑 NATIVE: Setting up native keyboard event listener');
// Listen for native escape key events
const subscription = DeviceEventEmitter.addListener('NativeEscapeKeyPressed', () => {
console.log('🔑 NATIVE: Escape key received from native AppDelegate');
if (isVisible) {
console.log('🔑 NATIVE: Closing TaskDetailPanel via native event');
closePanel();
}
});
return () => {
subscription.remove();
console.log('🔑 NATIVE: Native keyboard event listener removed');
};
}, [isVisible, closePanel]);
return (
<View style={{ flex: 1 }}>
{children}
</View>
);
};
// Wrap your app with the keyboard wrapper
function App() {
return (
<GlobalKeyboardWrapper>
{/* Your app content */}
</GlobalKeyboardWrapper>
);
}
Key Benefits
- Reliable Event Capture: Native macOS event monitoring captures ALL key events
- No Chirping: Events are consumed at the native level (
return nil
) - No Focus Issues: Doesn’t interfere with text editing or UI focus
- Performance: Native event handling is faster than JavaScript
- Extensible: Easy to add more key combinations
Key Codes Reference
Common macOS key codes for event.keyCode
:
- Escape: 53
- Enter: 36
- Space: 49
- Arrow Up: 126
- Arrow Down: 125
- Arrow Left: 123
- Arrow Right: 124
Why This Works
- Native Level:
NSEvent addLocalMonitorForEventsMatchingMask
captures events before they reach React Native - Event Consumption: Returning
nil
consumes the event (like SwiftUI’s.handled
) - Selective Bridging: Only send events to React Native when needed
- Clean Separation: Native handles the mechanics, React Native handles the logic
This approach finally gave me reliable global keyboard shortcuts in React Native macOS with zero interference and zero chirping sounds! 🎉
Building cross-platform apps with React Native macOS. Sometimes you need to go native to get it right. 🚀