What to install in 2026
| Package | Status | Expo Go | Best for | Shape |
|---|---|---|---|---|
| expo-video | Current, maintained | ✅ Basic playback | Almost every Expo app | Player object + VideoView |
| react-native-video | Current, community | ❌ Dev build | Bare RN, exotic DRM/ads | Single <Video> component |
| expo-av (Video) | ⚠️ Deprecated | ✅ Yes | Nothing new — migrate off | Replaced by expo-video |
| react-native-youtube-iframe | Current | ✅ Yes | YouTube content only | A WebView, not a native player |
The important row is the third one. A large share of the React Native video tutorials still ranking today import { Video } from expo-av. That code still runs, and it is a migration you will be forced into later — so if you are starting now, start on expo-video.
expo-video: player and view are separate
npx expo install expo-videoThe mental model differs from expo-av: a player owns the source and playback state, and a view renders it. One player can move between views, which is what makes fullscreen transitions and picture-in-picture work without reloading the stream.
import { useVideoPlayer, VideoView } from 'expo-video';
import { useEvent } from 'expo';
import { Pressable, StyleSheet, Text, View } from 'react-native';
const SOURCE = 'https://cdn.example.com/clips/intro.m3u8';
export default function ClipPlayer() {
const player = useVideoPlayer(SOURCE, (p) => {
p.loop = true;
p.muted = true; // required for autoplay that users tolerate
p.play();
});
const { isPlaying } = useEvent(player, 'playingChange', {
isPlaying: player.playing,
});
return (
<View>
<VideoView
player={player}
style={styles.video}
contentFit="cover"
allowsFullscreen
allowsPictureInPicture
nativeControls={false} // draw your own overlay
/>
<Pressable
style={styles.overlay}
onPress={() => (isPlaying ? player.pause() : player.play())}
>
<Text style={styles.icon}>{isPlaying ? '❚❚' : '▶'}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
video: { width: '100%', aspectRatio: 16 / 9, backgroundColor: '#000' },
overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
icon: { color: '#fff', fontSize: 34 },
});Note aspectRatio rather than a fixed height. Video without a reserved aspect ratio causes the layout to jump when the first frame arrives, and on a feed that jump compounds into scroll position drift.
Background playback and picture-in-picture change the native project, so they are enabled through the config plugin and then need a rebuild — they cannot be switched on from JavaScript alone:
// app.json
{
"expo": {
"plugins": [
["expo-video", {
"supportsBackgroundPlayback": true,
"supportsPictureInPicture": true
}]
]
}
}react-native-video: one component
The community package keeps everything on a single component, which some teams prefer and which maps more directly onto older tutorials:
import Video from 'react-native-video';
<Video
source={{ uri: SOURCE }}
style={{ width: '100%', aspectRatio: 16 / 9 }}
resizeMode="cover"
controls
paused={!isActive}
repeat
onError={(e) => console.warn('playback failed', e)}
onBuffer={({ isBuffering }) => setBuffering(isBuffering)}
/>It requires a development build in Expo — npx expo run:ios — and the Expo config plugin if you want its notification controls or background modes. Choose it deliberately, for a capability you have confirmed you need; picking it by default means giving up the version alignment that expo install gives you for free.
The feed pattern: one player, many rows
Every short-video app starts with a FlatListwhere each row mounts its own player. It looks fine with five items and falls over at fifty — memory climbs, frames drop, and on Android you eventually hit the device’s decoder limit and rows render black. The fix is to let the list decide who is visible and play only that one:
const [activeId, setActiveId] = useState(null);
// must be stable — a new object here throws
// "Changing onViewableItemsChanged on the fly is not supported"
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 70 }).current;
const onViewableItemsChanged = useRef(({ viewableItems }) => {
setActiveId(viewableItems[0]?.item?.id ?? null);
}).current;
<FlatList
data={clips}
keyExtractor={(c) => c.id}
pagingEnabled
windowSize={3} // keep the mounted range small
removeClippedSubviews
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
renderItem={({ item }) => (
<ClipRow clip={item} isActive={item.id === activeId} />
)}
/>Inside ClipRow, react to isActive by calling player.play() or player.pause(), and show the poster image until playback actually starts. Two details make it feel professional: preload the next clip only, and always render a thumbnail underneath, because the first frame of a stream is never instant on cellular.
The list mechanics here are the same ones covered in the FlatList guide — video simply punishes you faster for getting them wrong.
Four bugs that will reach your inbox
- Silent on iPhone, fine everywhere else. iOS uses an ambient audio session by default, which obeys the physical silent switch. Set a playback audio session once at app start so sound plays regardless. Nobody catches this in the simulator, and roughly half of your iOS users keep their phone on silent.
- Shipping MP4s where you need HLS. A single large MP4 must download before it can seek, so a two-minute clip on a poor connection looks broken. HLS (
.m3u8) streams in segments and adapts bitrate. Both players handle it natively — this is a question for whatever service encodes your uploads. - No poster, no loading state. Between mount and first frame you show a black rectangle, which users read as a crash. Render the thumbnail underneath the player and fade it out on the first playing event.
- Players that outlive their screen. Navigating away without releasing playback leaves audio running under the next screen. Pause on blur — with Expo Router,
useFocusEffect— and let the player be cleaned up with the component.
Decide the shape before you write the player
Almost all of the difficulty above comes from one decision made too late: whether video is a detail in your app — a how-it-works clip on an onboarding screen — or the product, as in a feed. The first is thirty lines and one package. The second is a hosting bill, an encoding pipeline, a moderation policy, and the list work above.
If you are not sure which one you are building, it is cheaper to look at it than to argue about it. Describe the screen — “a vertical clip feed with autoplay on the visible item and a tap-to-mute control” — and ShipNative generates it as real React Native you can scroll on your own phone, then export and take further.