Skip to content
Mobile Apps

React Native and Expo on iPhone Duo and iOS 27

An upgrade playbook for React Native and Expo teams: the iOS 27 scene life cycle launch failure, layout traps on iPhone Duo, and a native bridge for the fold.

Ingenious Techlab TeamUpdated 14 min read
Contents
  1. Part 1: the iOS 27 launch failure
  2. Part 2: iPhone Duo layout
  3. Part 3: making the fold visible to JavaScript
  4. A plan, in order

React Native teams face two separate problems this autumn, and one is much more urgent than the other.

  1. iOS 27 can stop your app launching. Apps built with the iOS 27 SDK must use UIKit’s scene-based life cycle, and no stable React Native release supports it yet. This affects every React Native iOS app from its first iOS 27 build, iPhone Duo or not.
  2. iPhone Duo exposes layout shortcuts. A React Native app resizes correctly on the foldable, but common patterns break, and nothing in React Native can tell your JavaScript where the fold is.

This post takes them in that order. Status is as of the date at the top; the linked issues move quickly.

Part 1: the iOS 27 launch failure

Apple’s iOS 27 release notes: “Apps built with the latest SDK must adopt the scene-based life cycle or they fail to launch.” An unmodified Expo SDK 56 app, built with Xcode 27, stops at launch with:

Application failed to launch: UIScene life cycle is required for apps built
with this SDK. See Technote TN3187 for more information on migration.

That report, in expo/expo#46664, lists react-native: 0.85.3 in its environment.

Scene life cycle support by stack, checked 2026-09-20
StackStatusWhat it means for you
UIKit / SwiftUI (native)You migrateAdopt a scene manifest and a window scene delegate. SwiftUI apps using the App protocol already use scenes.TN3187
FlutterShipped in 3.41Default since 3.41; unmodified AppDelegates migrate automatically on build. Custom AppDelegates, and older Flutter versions, migrate by hand.Flutter migration guide
React Native0.88 release candidateScene life cycle support, including linking, is in the 0.88 release candidates — 0.88.0-rc.1 is current — and in no stable release. 0.88 stable is scheduled for 12 October. On 0.87 or earlier, add a SceneDelegate to your iOS project yourself.Commit · 0.88 on npm · Release schedule · Template PR for 0.87
ExpoShipped, opt-in on 57SDK 58 beta uses scenes by default. On SDK 57 the runtime shipped too, but you opt in: set ios.enableSceneSupport in expo-build-properties and run expo 57.0.23 or newer. SDK 56 still fails to launch under Xcode 27.SDK 58 beta · enableSceneSupport · SDK 57 runtime · Original issue
Status of the scene life cycle requirement by stack, checked on . This changes weekly — follow the links for the current state before you plan around it.

Where React Native and Expo stand

  • React Native core: scene life cycle support landed on 11 August. The change adds a SceneDelegate path, keeps the AppDelegate path for compatibility, and updates linking, window resolution and the HelloWorld template. It ships in the 0.88 release candidates — 0.88.0-rc.1 is current. It is not in any stable release — 0.87.1, the latest stable, doesn’t include it, and 0.88 stable is scheduled for 12 October 2026.
  • Community template for 0.87: a pull request adopting the scene life cycle so 0.87 apps launch with Xcode 27 (template#251) is open against 0.87-stable.
  • Expo: this is now solved on SDK 57 and 58. The scene runtime shipped on the sdk-57 branch, and you opt in with ios.enableSceneSupport in expo-build-properties on expo 57.0.23 or newer; SDK 58 beta uses scenes by default. Full details, including why the original backport was closed, in Expo SDK 58 beta, iOS 27 scenes and iPhone Duo.

If you are on Expo, stop here and go to the Expo post — your fix is a config flag, not a code change. For bare React Native there are two routes. Wait for 0.88 to go stable on 12 October and adopt its template changes, which is the cleaner long-term path because core now handles linking and window lookup for scenes. Or, if you need to ship an iOS 27 build before then, carry the migration yourself on 0.87 or earlier.

On 0.87 or earlier: add a scene delegate yourself

The community template PR shows the shape of the change for the current Swift template. It touches three files.

Info.plist gains a scene manifest pointing at a new SceneDelegate:

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>Default Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>

AppDelegate.swift stops creating the window. It keeps the launch options for the scene to use, and returns a scene configuration:

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?
  var reactNativeDelegate: ReactNativeDelegate?
  var reactNativeFactory: RCTReactNativeFactory?
  var launchOptions: [UIApplication.LaunchOptionsKey: Any]?

  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    self.launchOptions = launchOptions
    return true
  }

  func application(
    _ application: UIApplication,
    configurationForConnecting connectingSceneSession: UISceneSession,
    options: UIScene.ConnectionOptions
  ) -> UISceneConfiguration {
    let configuration = UISceneConfiguration(
      name: "Default Configuration",
      sessionRole: connectingSceneSession.role
    )
    configuration.delegateClass = SceneDelegate.self
    return configuration
  }
}

SceneDelegate.swift (new) creates the window from the scene and starts React Native there:

import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  var reactNativeDelegate: ReactNativeDelegate?
  var reactNativeFactory: RCTReactNativeFactory?

  func scene(
    _ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions
  ) {
    guard let windowScene = scene as? UIWindowScene else { return }

    let appDelegate = UIApplication.shared.delegate as? AppDelegate
    let rnDelegate = appDelegate?.reactNativeDelegate ?? ReactNativeDelegate()
    rnDelegate.dependencyProvider = RCTAppDependencyProvider()
    let factory = appDelegate?.reactNativeFactory ?? RCTReactNativeFactory(delegate: rnDelegate)

    reactNativeDelegate = rnDelegate
    reactNativeFactory = factory
    appDelegate?.reactNativeDelegate = rnDelegate
    appDelegate?.reactNativeFactory = factory

    let window = UIWindow(windowScene: windowScene)
    factory.startReactNative(
      withModuleName: "YourAppName",
      in: window,
      launchOptions: appDelegate?.launchOptions
    )
    self.window = window
    appDelegate?.window = window
  }
}

Keep appDelegate?.window = window — it isn’t decoration. React Native has shipped code that reads the app delegate’s window: #53602 was a crash from exactly that under a scene delegate. The fix was merged, but native libraries can make the same assumption.

What this template change does not cover, and you must test:

  • Deep links and universal links. With a scene delegate in place, UIKit delivers URLs to the scene, not the app delegate. A commenter on the Expo issue reports this breaking React Native’s initial-URL handling. The 0.88 core change updates linking for scenes; this 0.87 template diff doesn’t. Test a cold-start deep link, a warm deep link and a universal link before shipping.
  • Every native SDK that hooks app-delegate events — push notifications, analytics, attribution, crash reporting. Check each vendor’s iOS 27 notes.
  • Your own app-delegate customisations. The template diff assumes an unmodified template. Most production app delegates aren’t.

Expo

For a managed or prebuild Expo app, the migration belongs in Expo’s generated iOS project rather than in your code — and as of 20 September 2026 you do not have to do it yourself at all. Set ios.enableSceneSupport in expo-build-properties, run expo 57.0.23 or newer, and SDK 57 builds with Xcode 27. SDK 58 beta needs no flag. The community config plugins that circulated in the issue thread are no longer the answer, and one of them is still unpublished on npm; the Expo SDK 58 beta post has the flag, the version floor and the reasoning.

One Xcode 27 tooling change also hit Expo. npx expo run:ios failed because Xcode 27 manages simulators through Device Hub, and Expo CLI looked for Simulator.app (#46882). A fix has been merged in the CLI.

Part 2: iPhone Duo layout

What reaches a React Native app on iPhone Duo, and what does notWindow size and safe-area insets already flow from UIKit through the React Native runtime to your code, so layout can adapt. The new iOS 27.1 APIs — reserved regions for the fold and cameras, the hinge, arrangements and vertical-bar priorities — have no React Native binding. Reaching them requires a native module (swift) that you build and maintain.already flows throughYour JS / TypeScriptReact Native runtimeYoga layout, safe-area contextNative module (Swift)you build and maintain thisWindow size · safe-area insetsreported by UIKit on every foldreservedRegions · UIHingeInteractionarrangements · vertical-bar priorities (iOS 27.1)UIKit / SwiftUI on iOS 27.1
Your React Native app can be correct on iPhone Duo — resizing and respecting insets — without any native code. Knowing where the fold is needs a bridge nobody ships today. Status checked on the date shown at the top of this post.

The good news first. When iPhone Duo opens, closes or rotates, UIKit resizes your app’s root view, and React Native lays out again with the new size. Flexbox layouts adapt. useWindowDimensions() reports the new size. Safe-area insets from UIKit reach JavaScript through libraries such as react-native-safe-area-context.

The problems are shortcuts that assumed the screen never changes shape while the app runs.

Trap 1: a width captured once

Why a cached screen width breaks when iPhone Duo unfoldsTwo sequences. Top: the width is read once with Dimensions.get when the module loads; after the person unfolds the device, the layout still uses the old narrow width. Bottom: the width is read during render with useWindowDimensions; when the device unfolds the hook reports the new size and the component re-renders to fill the display.App starts closedDimensions.get() at module loadPerson unfoldswindow gets much widerLayout still uses old widthnarrow column, dead spaceApp starts closeduseWindowDimensions() in renderPerson unfoldshook reports the new sizeComponent re-renderslayout fills the display✗ Read once, at module scope✓ Read during render
Opening and closing iPhone Duo resizes your app while it is running. Any width captured before that — in a module-scope constant, a StyleSheet built from it, or a percentage of it — is wrong the moment the device changes shape.
// Breaks on iPhone Duo — evaluated once, when the module loads
import { Dimensions, StyleSheet } from 'react-native';

const { width } = Dimensions.get('window');

const styles = StyleSheet.create({
  card: { width: width * 0.9 },
});

Anything derived from that value — a StyleSheet entry, a “responsive” percentage, a column count — stays at the closed-device width after the device unfolds. Read the size during render instead:

import { useWindowDimensions, View } from 'react-native';

function Card({ children }: { children: React.ReactNode }) {
  const { width } = useWindowDimensions();
  return <View style={{ width: width * 0.9 }}>{children}</View>;
}

Trap 2: orientation locks

Apple is explicit: iPhone Duo’s inner display doesn’t honor your supported interface orientations. A React Native app that locks to portrait — through Info.plist or an orientation-locking library — can’t rely on that lock on the inner display. Layouts that only ever handled one tall, narrow shape are where this shows.

Branch on available space, not orientation or device type. A single width breakpoint handles the outer display, the inner display in either orientation, and Split View:

const { width } = useWindowDimensions();
const columns = width >= 600 ? 2 : 1;

Device-type checks answer the wrong question. Apple’s guidance is to avoid assumptions about display size based on idiom, because iPhone Duo is still an iPhone with a very different amount of space.

Trap 3: symmetric safe-area maths

On iPhone Duo, toolbars and tab bars can sit along one side. Apple’s guidance: safe areas are often asymmetric, and code should handle each side independently.

Why doubling one safe-area inset breaks layouts on iPhone DuoTwo panels of the inner display in landscape with a vertical bar on the leading edge. Left, labelled wrong: content width is calculated as the bounds minus twice the left inset, leaving a wasted strip on the right. Right, labelled correct: each side's inset is applied independently and the content fills the available space.✗ Assume both insets are equalwasted
let width = view.bounds.width
    - view.safeAreaInsets.left * 2
✓ Handle each side independently
let width = view.bounds
    .inset(by: view.safeAreaInsets).width
Apple’s own example. With bars on one side, the left and right insets are no longer equal — so any layout maths that doubles one side leaves dead space on the other, or clips content if it doubles the smaller one.Proportions come from Apple’s published pixel dimensions. Widths of insets, bars and the fold are illustrative — Apple has not published those values.

The same bug is common in React Native:

import { useSafeAreaInsets } from 'react-native-safe-area-context';

const insets = useSafeAreaInsets();

// Wrong on iPhone Duo — assumes both sides match
<View style={{ paddingHorizontal: insets.left }} />

// Right — each side on its own
<View style={{ paddingLeft: insets.left, paddingRight: insets.right }} />

Trap 4: tab bars drawn in JavaScript

Where toolbars and tab bars go on iPhone DuoFive panels. A typical iPhone has bars at the top and bottom. iPhone Duo's outer display moves them to the side. The inner display in portrait keeps horizontal bars. The inner display in landscape puts them on the side. In Split View, each of the two apps places its bars along its own outer edge.TypicaliPhoneBars topand bottomDuo outerdisplayBars moveto the sideDuo inner,portraitKeepshorizontal barsDuo inner,landscapeBars onthe sideApp AApp BInner,Split ViewEach app’s barson its outer edge
The system does this automatically for standard bars — UINavigationController, UITabBarController, SwiftUI toolbars and TabView. Apple’s sample code notes that content from bars you place on screen yourself won’t be considered, so hand-built toolbars are where apps break.Proportions come from Apple’s published pixel dimensions. Widths of insets, bars and the fold are illustrative — Apple has not published those values.

iPhone Duo moves tab bars and toolbars to the side of the outer display, and of the inner display in landscape — for the system’s own bars. Apple’s sample code notes that content from bars you place yourself won’t be considered.

This is our analysis, and it matters for most React Native apps. A tab bar rendered by a JavaScript navigation library is a row of ordinary views, not a system tab bar, so iOS has no reason to move it. Expect it to stay a horizontal strip at the bottom of a wide display, where Apple’s own apps put controls on the side. Tab implementations backed by native UIKit tab bar controllers are the ones positioned to pick up the system behaviour. Treat the switch as a design decision to test in Device Hub — which you can do now, in the Xcode 27.1 beta — not an assumption.

Part 3: making the fold visible to JavaScript

iOS 27.1 adds reserved regions: areas of the screen your UI should avoid. On iPhone Duo these are the camera regions and the fold.

iPhone Duo's reserved regions: two cameras and the foldLeft, the outer display with the outer front camera region, which is always present. Right, the inner display with two regions: the folding region down the centre, present only when the device is partially open, and the inner front camera region, present only while the camera is active.Outer displayOuter cameraalways presentInner display, partially foldedFOLDFolding regiononly when partially open.divisionFaceTime cameraonly while active.occlusion
System components such as alerts, sheets and split views move out of these regions on their own. Custom UI does not. In iOS 27.1 you query the fold with reservedRegions(kind: .division) and the inner FaceTime camera with .occlusion. When the device is flat the fold’s region is inactive with a width of zero.Proportions come from Apple’s published pixel dimensions. Widths of insets, bars and the fold are illustrative — Apple has not published those values.

Apple’s design guidelines say to keep important elements clear of the centre when the system doesn’t move them automatically, and to prefer an even number of columns in grids so content divides cleanly. React Native has no API for any of this, and none has been announced. A React Native app can be correct on iPhone Duo — resizing and respecting insets — while having no idea the fold exists.

If your product needs to know — a video call that puts faces on either side, a reading app that paginates across halves, a game — you need a native module.

A proposed React Native native module for iPhone Duo fold dataLeft: UIKit's reserved regions and hinge interaction on iOS 27.1. Middle: a proposed Swift native module observes them, converts region frames to JSON, and sends change events through an event emitter. Right: a TypeScript hook exposes the fold state to your screens so they can avoid the fold or split the layout.UIKit, iOS 27.1Native module — proposedJavaScriptview.reservedRegions(kind:).division · .occlusionUIHingeInteractionstatus + angleDuoFoldModuleobserves, converts to JSON framesEvent emitterfoldStateDidChangeuseFoldState()TypeScript hookYour screensavoid the fold, split the layout
A design, not a shipped package. The Swift side uses Apple’s published API names. The SDK it needs arrived in the Xcode 27.1 beta on 18 September 2026, but this design has not been compiled against it — we will update the post with a tested implementation.

Here is the JavaScript side. It uses only long-established React Native APIs, so it works today and returns an empty array on any device without a fold:

import { useEffect, useState } from 'react';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';

export type FoldRect = { x: number; y: number; width: number; height: number };

const native = Platform.OS === 'ios' ? NativeModules.DuoFoldModule : undefined;
const emitter = native ? new NativeEventEmitter(native) : undefined;

/** Active fold regions, in points. Empty when flat or on a device without a fold. */
export function useFoldState(): FoldRect[] {
  const [folds, setFolds] = useState<FoldRect[]>([]);
  useEffect(() => {
    if (!emitter) return;
    const sub = emitter.addListener('foldStateDidChange', (e: { folds: FoldRect[] }) => setFolds(e.folds));
    return () => sub.remove();
  }, []);
  return folds;
}

The native side reads the fold with the API Apple showed in the “Strike a pose” Tech Talk, view.reservedRegions(kind: .division), and forwards the frames:

// NOT COMPILED: requires the iOS 27.1 SDK in Xcode 27.1, which is not yet
// released. API names are from Apple's Tech Talk sample code.
@objc(DuoFoldModule)
final class DuoFoldModule: RCTEventEmitter {
  override func supportedEvents() -> [String]! { ["foldStateDidChange"] }
  override static func requiresMainQueueSetup() -> Bool { true }

  /// Call on layout changes of the root React Native view.
  func publishFolds(for rootView: UIView) {
    let frames = rootView.reservedRegions(kind: .division).map(\.frame)
    sendEvent(withName: "foldStateDidChange", body: [
      "folds": frames.map { ["x": $0.minX, "y": $0.minY, "width": $0.width, "height": $0.height] }
    ])
  }
}

Three design notes for whoever builds this for real:

  • By default only active regions are returned. Apple’s talk says the fold’s division region is active only when someone has folded the device, and has zero width when flat. An empty array therefore means “no fold right now,” which is the value your layout wants.
  • Use the hinge for effects, never for layout. UIKit’s UIHingeInteraction reports the hinge as closed, partially open or fully open, plus a continuous angle. Apple positions hinge data for interactions and effects; layout should come from regions and size. Apple hasn’t published code for UIHingeInteraction yet, so we don’t show any.
  • Don’t ship this before you can test it. Until the Xcode 27.1 simulator exists, this is a design, not a module.

A plan, in order

  1. Now: build with Xcode 27 on a branch. Choose your scene life cycle route — React Native 0.88 when stable, a scene delegate of your own on 0.87, or Expo’s release — and test deep links, notifications and every native SDK.
  2. Now: search the codebase for Dimensions.get, paddingHorizontal: insets, orientation locks and device-type checks. Replace them with useWindowDimensions(), per-side insets and width breakpoints.
  3. Now: decide whether your tab bar should become a native one.
  4. Now, in the Xcode 27.1 beta: run the app in Device Hub on the iPhone Duo simulator and go through every pose and Split View. Widgets and share extensions are the exception — the simulator will not run them.
  5. Only if your product needs it: build the fold bridge.

Our rough planning estimate for an established app, not a quote: steps 1 and 2 are typically days to a couple of weeks, depending on how customised the iOS start-up code is. Moving a JavaScript tab bar to native tabs, or building the fold bridge, is a separate, larger piece of work worth scoping on its own.

For a per-issue reference of what breaks on iPhone Duo, with diagrams for each case, see the React Native guide on iphoneduosupport.com. For the iOS 27 changes beyond the scene life cycle, see iOS 27 for developers.

Apple sources

Checked against these sources on . Apple’s written “Preparing your app for iPhone Duo” documentation and Xcode 27.1 beta were still listed as coming later this month on that date, so the Tech Talks and the Human Interface Guidelines are the authoritative references. We will update this post when the written documentation ships.

This post is part of our work onMobile app development for iOS and Android

Related reading

Let's talk

Find out what iPhone Duo and iOS 27 mean for your app

Send us your App Store link and the stack it is built on. An engineer replies with what breaks, what to fix first, and a realistic effort estimate — no call and no obligation.

  • A written reply from an engineer
  • No call required
  • No obligation