Upgrading to Expo 54: From Passing Types to Runtime Compatibility
The difficult part of upgrading a shipped React Native application often starts after dependency installation succeeds. A screen opens, but a default callback becomes undefined. Type checking passes, but a file API fails at runtime. A debug build works, while release configuration and compatibility with older installed binaries remain untested.
I encountered these different layers while leading an Expo SDK 54 migration. The work covered React Native 0.81.5, React 19.1, native project changes, build tooling, and regression checks. This article focuses on the decisions that helped turn a version update into a migration with reviewable evidence.
The examples are simplified and use generic names. They explain this migration; they are not a universal upgrade script.
Establish the migration boundary first

Technical flow; implementation and validation boundaries are explained below.
The application already had native directories, custom initialization, third-party SDKs, and dependency patches. We retained the Legacy Architecture and Hermes for this migration, leaving the architecture transition as a separate task. SDK 54 still supports the Legacy Architecture, but it is the final SDK release to do so. That decision therefore carries future migration work. Expo SDK 54 release notes
Before editing, I established a baseline across five layers:
| Layer | What needed checking |
|---|---|
| JavaScript | The compatible Expo, React Native, React, and dependency versions |
| Native projects | Initialization, custom modules, permissions, and SDK integrations |
| Toolchain | Node, Gradle, AGP, Kotlin, NDK, and the versions actually resolved |
| Runtime behavior | Component defaults, file operations, interactions, and existing patches |
| Delivery | Native binaries, JavaScript bundles, and runtime compatibility |
Declared configuration and actual build inputs can differ. Updating package versions while retaining an outdated native initialization path does not complete a migration.
Compare templates against the application you actually maintain
I compared the matching Expo template, the RN upgrade diff, and the application's implementation. A bare React Native diff needs interpretation when the project also uses Expo and maintains native code.
For example, we aligned Android's RN initialization with the corresponding loadReactNative entry point while preserving the ordering of application SDK initialization. Gradle, AGP, Kotlin, KSP, and NDK also needed review as a compatible toolchain, rather than as unrelated candidates for individual upgrades. Expo SDK 54 native template
Existing system-bar and inset handling required similar care. Keeping custom Activity behavior does not remove the need to handle platform edge-to-edge requirements. A framework configuration flag, application layout ownership, and OS-enforced behavior are separate concerns. We needed to document who owned the layout behavior and which screens and OS versions were checked.
I classified differences as confirmed compatibility gaps, intentional application choices, or risks needing further validation. That made both changes and deliberate omissions reviewable.
React 19: a type fix cannot restore runtime defaults
Compare the layer that changes before and after the fix
This example follows the clustering-dependency scenario and retains only parameter binding. Values and names are illustrative; Props, noop and rendering are omitted. The two same-named functions are a before/after comparison, not one executable module.
// Before: function-component defaults depended on React.
function ClusterLayer(props: Props) {
const radius = props.radius;
// ...the dependency's clustering and rendering logic...
}
ClusterLayer.defaultProps = { radius: 50, onClusterPress: noop };
// After: defaults are applied by JavaScript parameter binding.
function ClusterLayer({
radius = 50,
onClusterPress = noop,
...rest
}: Props) {
// ...the same clustering and rendering logic...
}
// Call-site cases that must keep their meaning:
// omitted / undefined -> default
// radius={0} -> 0, not 50
// explicit callback -> caller's callback, not noop
The change supplies values when the function executes; changing an optional type to a required type would not do that. radius || 50 loses a valid zero, while radius ?? 50 also treats explicit null as missing, unlike parameter defaults. Check the dependency's accepted inputs and regress the actual exported component.
One issue involved function-component defaultProps. React 19 no longer applies this mechanism to function components; class components retain support. React 19 upgrade guide
A map-clustering dependency in our application used it for default configuration and callbacks. Omitting a prop could therefore leave a downstream operation with a value the library did not expect.
This generic button illustrates moving defaults into the function parameters. It is explanatory code, not copied application code:
import { Button } from 'react-native';
type RetryButtonProps = {
label?: string;
onRetry?: () => void;
};
const noop = () => {};
function RetryButton({
label = 'Retry',
onRetry = noop,
}: RetryButtonProps) {
return <Button title={label} onPress={onRetry} />;
}
Each migrated default still needs a semantic review, including callbacks, numeric calculations, and layout options. Parameter defaults apply only when the input is undefined; explicit values must retain priority. A truthiness-based fallback can incorrectly replace valid values such as false or 0.
Our application also injected some application-wide component defaults during initialization, including font-scaling behavior. After the upgrade, omitted props could fall back to RN's own behavior, changing layouts at large system font sizes.
Changing a TypeScript declaration could not repair that behavior. We handled affected components according to their rendering paths: parameter defaults for functions we controlled, narrow patches for confirmed dependency issues, and render-time handling where dynamic application defaults were required. Explicit caller props retained priority. This compatibility work has maintenance costs and should not be treated as a default architecture for other applications.
A text search was only the start of the audit. A dependency containing defaultProps was not automatically a production defect: it might use a class, merge defaults itself, or receive explicit values from every relevant caller. We checked the actual call sites before deciding what to patch.
The regression checks also needed to exercise the modified runtime implementation. A test wrapper that supplies missing defaults only proves that the wrapper works.
Preserve existing API behavior while migrating
Existing file operations used string URIs, directory constants, and read, write, upload, and download APIs. SDK 54 provides the newer object API while retaining an official legacy entry point. Expo FileSystem legacy documentation
We used that entry point to preserve the established calling model:
import * as FileSystem from 'expo-file-system/legacy';
This separated the SDK upgrade from a future migration to File and Directory. It did not remove the need to inspect supported options, return values, and error handling. Separating the changes made potential regressions easier to attribute.
Localization calls moved to getLocales() and getCalendars(). The following shows the reading pattern only:
import { getCalendars, getLocales } from 'expo-localization';
const regionCode = getLocales()[0]?.regionCode;
const timeZone = getCalendars()[0]?.timeZone;
Application logic still needs to handle missing values without overwriting an existing user selection. A one-time read also does not solve every lifecycle-related refresh requirement; that depends on the consuming screen. Expo Localization documentation
We removed broad compatibility declarations that hid real API mismatches. Remaining type adaptations represented confirmed equivalent behavior. Declarations cannot create an API that no longer exists at runtime.
Review dependency patches and OTA compatibility separately
Existing patches often encode earlier production fixes. For each patch, I checked whether the newer dependency had already addressed the issue, whether the affected path remained reachable, whether lifecycle assumptions had changed, and which platform was affected.
Only the behavior still needed was carried forward. This preserved necessary fixes without importing obsolete implementation details wholesale.
A concrete example was the iOS controlled TextInput cursor issue. The upstream fix covered the New Architecture; I adapted its approach to Paper, including effective attribute comparison and input-update safeguards, then maintained the existing RN 0.77.3 patch for RN 0.81.5. The legacy TextInput case study explains the implementation, public patch, and validation scope.
A separate New Architecture adaptation involved taps failing after scrolling on an iPhone X running iOS 16.7.10. I traced native events into JS, identified inconsistent responder and touch coordinates, and applied a wrapper on the affected page. That case has its own version context and was not a newly introduced Expo 54 issue. See the iOS scrolling and touch investigation.
Native changes also affect OTA compatibility. Producing a JavaScript bundle does not prove that an older installed binary can run it. That binary must contain the native capabilities the bundle expects. Native dependency changes therefore require a review of runtime versions and update targeting. Expo runtime versions
We treated a successful run on a newly built app and compatibility with an older app as separate questions. Delivery notes needed to identify the binary, compatibility boundary, and bundle used for validation.
Report validation by what it actually proves
The recorded checks for this migration included lockfile-constrained installation, type checking, relevant lint checks, 113 Jest suites with 509 tests, Android Kotlin compilation, and an Android JavaScript export. The project documentation also records self-tests for sending and saving images and uploading video after the file API adaptation.
Each result has a specific scope:
| Check | Evidence provided | What it does not replace |
|---|---|---|
| Installation and type checking | Reproducible dependencies and compilable types | Runtime behavior on devices |
| Jest | The executed assertions passed | Uncovered native lifecycles and business flows |
| Native compilation and JS export | Those compilation and packaging stages succeeded | Installation, execution, release settings, and external uploads |
| Targeted functional checks | Expected behavior on the recorded paths and devices | Other devices, OS versions, and untested paths |
The 113 suites and 509 tests describe the existing regression run, not newly added tests or complete business coverage. Release parameters, optimized artifacts, distribution, and device regression remain separate validation responsibilities.
Leave a migration another engineer can maintain
I organized the migration notes around symptoms, causes, changes, validation, and remaining boundaries. Type changes, runtime behavior, native compilation, and release risks had distinct evidence instead of sharing one broad “upgrade passed” label.
That record becomes useful again during the next upgrade. Another engineer can see why a template difference was retained, which patch still serves a purpose, and which checks should be repeated.
ivan works on React Native, Expo, and iOS migrations, troubleshooting, and delivery workflows. Available for part-time remote technical support through written, asynchronous communication.
