
6 Expo UI Tips I Learned Building Production Apps
Beto - March 2026
I've been using Expo UI since the alpha days, and now with the beta in Expo SDK 55 it's come a long way. But there are still a few things that aren't obvious, and if you don't know them your app just won't feel polished.
I've been building production apps with Expo UI, Inkigo (an AI tattoo preview app) and Platano (a new AI image app template shipping soon), and today I'm sharing six tips that will save you hours of debugging.
If you prefer video, I put together a YouTube tutorial that walks through everything covered here.
1. The ignoreSafeArea Prop on Host
By default, Expo UI's SwiftUI hosting view tries to avoid the keyboard safe area. This sounds helpful until it starts pushing your content around in ways you don't expect.
I ran into this building Inkigo. I had an input using KeyboardStickyView from react-native-keyboard-controller, position absolute, measurements were correct, but when the keyboard opened there was way too much space between the keyboard and the input. The SwiftUI host was trying to move things so they wouldn't be covered by the keyboard, on top of my own keyboard handling.
The fix is the ignoreSafeArea prop on the Host component:
<Host matchContents ignoreSafeArea="keyboard">
{/* your SwiftUI content */}
</Host>Setting ignoreSafeArea="keyboard" tells the host to stop performing keyboard avoidance. You handle it yourself.
You can also use ignoreSafeArea="all" for elements like floating buttons that shouldn't move at all when the keyboard appears. In Inkigo, I use this on a button at the bottom-right of an image. Without it, the button shifts slightly every time the keyboard opens and closes.
2. matchContents
Think of the Host component as a window for accessing your SwiftUI view. Without matchContents, you need to manually set width and height on that window. Guess wrong and bad things happen:
- Too small? Touches won't register outside the window boundaries.
- Too big? Wasted space and layout issues.
- Using
flex: 1? SwiftUI views often don't know how to fill flex-based layouts.
The problem is you often don't know the exact size of your SwiftUI content. Instead of guessing, use matchContents:
<Host matchContents>
<TextField placeholder="Enter text" multiline />
</Host>This automatically sizes the host to match the inner SwiftUI view exactly. No manual width/height, no flex hacks. Every touch registers, the layout is clean.
3. RNHostView
Here's the architecture: React Native uses UIKit by default. When you use Expo UI, you create Host views to render SwiftUI components. But the Host only accepts native components from Expo UI. If you try to use a regular React Native View inside it, it'll break.
RNHostView solves this. It lets you host a React Native view inside a SwiftUI view.
In Platano, the entire settings screen is a SwiftUI Form with native Picker, Slider, Toggle, and ColorPicker components. But I wanted a live preview showing how the theme looks as you change settings. That preview is built with React Native views:
<Host style={{ flex: 1 }}>
<Form>
<Section title="Preview">
<RNHostView matchContents>
<View style={{ paddingHorizontal: 30 }}>
<View style={{
backgroundColor: brandColors.primary,
padding: SPACING.MD,
borderRadius: BORDER_RADIUS.MD,
}}>
{/* React Native preview content */}
</View>
</View>
</RNHostView>
</Section>
<Section title="Appearance">
<Picker label="Spacing" ... />
<Slider ... />
</Section>
</Form>
</Host>The whole screen is SwiftUI, but RNHostView lets you drop in React Native primitives wherever you need them. You won't use it every day, but when you need it, it's a lifesaver.
4. Platform Extensions for Components and Routes
Platform extensions (.ios.tsx, .android.tsx) aren't new to React Native, but they become critical with Expo UI. Since you're writing SwiftUI for iOS and Jetpack Compose for Android, importing the wrong platform's components will crash your app.
Here's how I structure components in Platano:
components/generation/
InputControls.tsx # default (iOS) - SwiftUI components
InputControls.android.tsx # Android fallback - RN primitives
InputControls.types.ts # shared types for bothThe iOS version imports from @expo/ui/swift-ui:
import { Host, TextField, VStack, HStack } from "@expo/ui/swift-ui";
import { glassEffect, buttonStyle } from "@expo/ui/swift-ui/modifiers";The Android version uses standard React Native:
import { TextInput, Pressable, View, StyleSheet } from "react-native";Both implement the same InputControlsProps interface from the shared types file. When the app compiles, the bundler automatically picks the right file for each platform.
One important rule: always have at least one file without a platform extension as the default. That's your fallback.
5. Centralize Your API
When you have platform-specific implementations, it's tempting to put all the logic (state, fetch requests, types) inside each platform file. Don't do that. You'll end up duplicating everything.
Instead, lift shared logic into a context or hook. Think of it as building a mini package that both platform implementations consume.
In Platano, both InputControls.tsx (iOS) and InputControls.android.tsx (Android) pull from the same context:
// Both platforms use the exact same API:
const { SPACING } = use(AppSettingsContext);
const { inputControlsRef } = use(GenerationContext);The shared types file defines the contract once:
// InputControls.types.ts
export interface InputControlsHandle {
focus: () => void;
blur: () => void;
setText: (text: string) => void;
}
export interface InputControlsProps {
prompt?: string;
onChangeText?: (text: string) => void;
onSubmit?: () => void;
onPressAdd?: () => void;
}Both platforms implement useImperativeHandle with the same InputControlsHandle interface. Same API surface, completely different native implementations underneath. The AppSettingsContext provides SPACING, BORDER_RADIUS, and BORDER_WIDTH tokens that both platforms consume without knowing where the values come from.
This is how you keep your codebase maintainable as you add more native screens.
6. Use Platform Colors
Expo Router (SDK 55) ships with a Color utility that gives you access to native platform colors. Instead of defining your own color tokens, you can tap directly into system colors that automatically adapt to light/dark mode.
For iOS, you get semantic system colors:
import { Color } from "expo-router";
Color.ios.systemBackground;
Color.ios.secondarySystemBackground;
Color.ios.label;
Color.ios.secondaryLabel;
Color.ios.separator;For Android, you get dynamic surface colors from Material You:
Color.android.dynamic.surfaceContainer;
Color.android.dynamic.onSurface;
Color.android.dynamic.onSurfaceVariant;
Color.android.dynamic.outlineVariant;
Color.android.dynamic.primary;Here's how I use them in Platano. A single function resolves the right color per platform:
import { Color } from "expo-router";
import { Platform } from "react-native";
const defaults = Platform.select({
ios: {
background: Color.ios.systemBackground,
text: Color.ios.label,
separator: Color.ios.separator,
},
android: {
background: Color.android.dynamic.surfaceContainer,
text: Color.android.dynamic.onSurface,
separator: Color.android.dynamic.outlineVariant,
},
});The best part? On Android, these dynamic colors adapt to the user's wallpaper. Change the wallpaper color and the entire system UI theme changes, and your app automatically picks up those new colors. Light and dark mode are handled automatically too. No theme provider needed for basic system color support.
Want to go deeper?
These are the small things that make a massive difference when working with Expo UI. From handling the keyboard properly with ignoreSafeArea, to using matchContents for correct sizing, bridging React Native views with RNHostView, platform extensions for clean separation, centralized shared logic, and native platform colors.
Most of these aren't obvious when you first start with Expo UI, but once you know them everything just clicks.
Now, Expo UI is still in beta. That's the main reason we haven't added it to the React Native course yet. I don't want to teach patterns that might shift before the stable release. But we're using it heavily in production apps like Inkigo and Platano, and I'm convinced it's going to be a core part of how we build with React Native and Expo going forward. We're putting in the hours now so that when the stable version drops, we can deliver lessons that are battle-tested and actually useful from day one.
In the meantime, the React Native course covers everything you need to build polished production apps today. And Pro Members get full access to production codebases like Inkigo and Platano where you can already see these Expo UI patterns in context.
I share practical tips, experiments, and updates in my newsletter if you want to stay in the loop. Expo UI content will be one of the first things we cover when it goes stable.
Want to partner with me? You can reach me at beto@codewithbeto.dev.
Join the newsletter