skip to content

In a React Native iOS Turbo Module, how should UIKit work reach the main thread, and what do requiresMainQueueSetup and methodQueue change?

level: seniorimportance: should knowfreq 25%

answer

  1. async calls arrive off the main thread
  2. dispatch_async to the main queue
  3. methodQueue is marked deprecated
  4. custom init implies main-queue setup
  5. unstableRequiresMainQueueSetup runs before JS

basics

~20 s

Asynchronous Turbo Module methods arrive on a background method queue, so UIKit calls must be dispatched to the main queue inside the method. +requiresMainQueueSetup only decides where the module is constructed; overriding methodQueue to force every call onto main is deprecated.

solid answer

~40 s

Unless a module supplies its own queue, React Native runs its `void` and `Promise` methods on a serial background queue shared with other such modules, and value-returning methods on the JavaScript thread. UIKit is main-thread-only, so wrap just the UIKit part in `dispatch_async(dispatch_get_main_queue(), ^{ ... })` (or `RCTExecuteOnMainQueue`) and settle the promise from there. Overriding `methodQueue` to return the main queue still works, but `RCTBridgeModule.h` marks it deprecated and says to dispatch explicitly. `+requiresMainQueueSetup` returning `YES` makes React Native construct the module on the main thread; return `NO` when `init` does not touch UIKit, because without the method a custom `init` makes React Native assume main-queue setup. The Codegen key `unstableRequiresMainQueueSetup` goes further and initializes the module on main before any JavaScript runs.

code

objective-cpp · 37 lines
objective-cpp
#import <React/RCTUtils.h> // RCTPresentedViewController

@implementation RCTSmartLock {
  SmartLockAdapter *_adapter;
}

// init only builds a Swift object; it needs no UIKit
+ (BOOL)requiresMainQueueSetup
{
  return NO;
}

- (instancetype)init
{
  if (self = [super init]) {
    _adapter = [SmartLockAdapter new];
  }
  return self;
}

// pairLock(lockId: string): Promise<void>
- (void)pairLock:(NSString *)lockId
         resolve:(RCTPromiseResolveBlock)resolve
          reject:(RCTPromiseRejectBlock)reject
{
  dispatch_async(dispatch_get_main_queue(), ^{
    UIViewController *presenter = RCTPresentedViewController();
    if (presenter == nil) {
      reject(@"E_NO_PRESENTER", @"No view controller to present from", nil);
      return;
    }
    [self->_adapter presentPairingFrom:presenter lockId:lockId];
    resolve(nil);
  });
}

@end

go deeper

for a junior

Remember that native module methods do not run on the main thread by default, so UIKit calls need a dispatch to the main queue.

for a middle

Explain the default thread for async and sync methods, how dispatch_async to main fits inside a Promise method, and what requiresMainQueueSetup does to construction.

for a senior

Diagnose main-thread-checker warnings, launch regressions from implicit main-queue setup, and hitches from a main methodQueue, and fix each with the current per-call pattern.

for a principal

Set a team rule for native modules: no main methodQueue, explicit NO for requiresMainQueueSetup, and unstableRequiresMainQueueSetup only with a measured startup budget.

## Where a Turbo Module's code runs On iOS, React Native's Turbo Module manager decides which thread executes each call. Three kinds of code matter: | Code | Default thread | Source of the rule | |---|---|---| | `void` and `Promise` spec methods | The module's **method queue**; if the module supplies none, a **serial queue shared** with other such modules | Asynchronous calls are dispatched there | | Value-returning spec methods | The **JavaScript thread**, synchronously | JavaScript waits for the return value | | Construction (`init`) | The thread that first requests the module, or the **main thread** when main-queue setup is required | `+requiresMainQueueSetup` and its inference | None of the defaults is the main thread for method calls. **UIKit**, however, must be used from the main thread — presenting the smart-lock vendor's pairing sheet, reading `UIApplication` state, or touching a `UIView` from a background queue is a bug even when it appears to work. ## Getting UIKit work onto main, per call The pattern React Native itself now recommends is **explicit dispatch** around exactly the code that needs it: 1. Do non-UI work — argument validation, SDK calls — on the queue the call arrived on. 2. Wrap only the UIKit part in `dispatch_async(dispatch_get_main_queue(), ^{ ... })`. React Native's `RCTExecuteOnMainQueue` does the same but runs the block immediately if you are already on main. 3. Call `resolve` or `reject` from inside that block once the UI work has finished, so JavaScript observes the result after the sheet is on screen. 4. Keep the block short; long work on main drops frames. For presenting UI, React Native's `RCTPresentedViewController()` helper returns the view controller currently on top, which is the usual anchor for a vendor's sheet; like any UIKit read, call it inside the main-queue block. Never do the reverse from a **synchronous** method: calling back onto main with `dispatch_sync` while the JavaScript thread waits stalls JavaScript on the UI thread and risks deadlock. React Native's own synchronous helper is named `RCTUnsafeExecuteOnMainQueueSync`, and its header asks you not to use it unless you know what you are doing. ## methodQueue: the old lever Legacy modules could override `- (dispatch_queue_t)methodQueue` to return `dispatch_get_main_queue()`, which moved **every** asynchronous method onto main. The Turbo Module manager still honours an overridden queue for backward compatibility, but in React Native 0.87 `RCTBridgeModule.h` marks the property `RCT_DEPRECATED` and gives two replacements: - **for a private work queue**, create your own with `dispatch_queue_create` and dispatch to it; - **for main-thread work**, dispatch to the main queue directly where you need it. Forcing all methods onto main also serializes harmless work behind UI work, so the per-call pattern is faster as well as current. ## requiresMainQueueSetup: where the module is born `+ (BOOL)requiresMainQueueSetup` is about **construction**, not about method calls: - **`YES`** — React Native constructs the module on the main thread, making the requesting thread wait for it. Use it only when `init` genuinely captures UIKit state. - **`NO`** — the module may be constructed on any thread, which is the cheap and usual answer. - **Not implemented** — React Native's backward-compatibility logic infers `YES` if the class overrides `init` or implements `constantsToExport`. A module whose `init` merely creates a Swift adapter therefore pays for main-queue setup until it returns `NO` explicitly. The Codegen configuration offers a stronger variant, `codegenConfig.ios.modules.<Name>.unstableRequiresMainQueueSetup`, documented as initializing the module **on the UI thread before any JavaScript runs**. React Native builds those modules eagerly at startup and the JavaScript bundle waits for them, so every entry costs launch time; the `unstable` prefix is a warning to use it sparingly. ## A diagnostic checklist - **Main-thread checker warnings in Xcode** from inside a module method — a UIKit call on the method queue; wrap it in a main-queue dispatch. - **Launch time grew after adding a module** — look for an implicit `YES` from a custom `init`, or an `unstableRequiresMainQueueSetup` entry. - **The whole app hitches when a method runs** — a `methodQueue` returning main, or long work inside a main-queue block. - **A synchronous getter hangs** — it is waiting on main while main waits on JavaScript; make the method asynchronous. - **A JavaScript `await` never returns after a UI error** — a branch inside the main-queue block returned without calling `resolve` or `reject`.

  • Why does a module with a custom init get constructed on the main thread even though it never asked to?
    When `+requiresMainQueueSetup` is not implemented, React Native's backward-compatibility logic assumes a class that overrides `init` or implements `constantsToExport` might touch UIKit, and sets it up on main. Implementing `+requiresMainQueueSetup` returning `NO` removes that cost when the initializer is UIKit-free.
  • When is unstableRequiresMainQueueSetup justified over +requiresMainQueueSetup?
    Only when the module must capture main-thread state before the first line of JavaScript runs, because it is built eagerly at startup and the bundle waits for it. For a module first used after launch, lazy construction via `+requiresMainQueueSetup` — or no main-queue setup at all — keeps startup cheaper.

saying these in an interview costs you the question

  • Turbo Module methods already run on the main thread, so UIKit calls are safe
  • requiresMainQueueSetup YES makes every method call run on the main queue
  • Overriding methodQueue to return the main queue is the current recommended pattern
  • Omitting requiresMainQueueSetup means the module is always set up off the main thread
  • dispatch_sync to main from a synchronous method is a safe way to read UIKit state