DEV Community

Cover image for I built an Instagram Reels feed in React Native. Here's what broke.
Arbab Rafiq
Arbab Rafiq

Posted on

I built an Instagram Reels feed in React Native. Here's what broke.

An open-source React Native toolkit for vertical video feeds: snap paging, bounded video players, tap and scrub gestures, and pluggable video adapters.

A vertical video feed looks simple: full-screen videos, swipe up for the next one. Then you build it, and the details pile up.

Every cell wants a native video player, so scrolling through twenty videos can mean twenty decoders. Videos keep playing when you open another screen or send the app to the background. Swipe hard and a naive list can fly past several videos. The progress bar jumps instead of moving. A tap should mute and a double tap should like, and neither should fight the scroll.

I packaged my answers to those problems as reels-kit, an open-source toolkit for React Native. This post covers what it does, how to use it, and a few things I learned along the way.

What it is

reels-kit is a set of small, headless building blocks around a FlashList-based feed. It handles the hard parts and leaves the layout to you.

  • Snap paging. One swipe moves one reel, with the page height measured from the container instead of the window.
  • Bounded players. Only the active reel and its neighbours mount a native player (mediaRenderRadius, default 1).
  • Playback that pauses when it should. A reel plays only when it is the active one, the screen is focused and the app is in the foreground.
  • Gestures. Tap to mute, optional double tap to like, and a drag-to-seek progress bar.
  • Failed loads. A thumbnail until the video is ready, a buffering slot, and a retry that remounts the player.
  • Your video engine. The core imports no video library. A react-native-video adapter ships in the package, and any other engine fits behind a small adapter interface.
  • No bundled icons. Every icon is a React node you pass in.

It is built on FlashList 2, Reanimated 4 and Gesture Handler 3, so it targets React Native 0.82 or newer with the New Architecture.

See it

Paging Tap to mute Double tap to like
Paging Tap to mute Double tap to like
Scrub bar Follow and caption
Scrub bar Follow and caption

Install

# npm
npm install reels-kit @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler

# yarn
yarn add reels-kit @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler

# pnpm
pnpm add reels-kit @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler

# bun
bun add reels-kit @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler
Enter fullscreen mode Exit fullscreen mode

If you want the bundled adapter, install react-native-video yourself. reels-kit never installs a video engine for you.

Then three setup steps:

  1. Add the worklets plugin to babel.config.js, listed last: plugins: ['react-native-worklets/plugin'].
  2. Wrap your app in GestureHandlerRootView. The scrub bar and the gesture hooks need it.
  3. Run pod install in ios/.

One compatibility note that cost me an install: Reanimated has to match your React Native version. Reanimated 4.7 needs React Native 0.86 or newer, while 4.5.3 supports 0.83 to 0.86. If your package manager reports a peer conflict, pin an older Reanimated.

A working feed

This is a complete, minimal feed. I type-checked it against the published package, and it is the same shape I ran on iOS and Android.

import { useCallback, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import {
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';
import { useRecyclingState } from '@shopify/flash-list';
import {
  FeedActionButton,
  FeedCaption,
  FeedVideoSlot,
  MuteIndicatorOverlay,
  ReelsFeed,
  ScrubBar,
  useFeedTapGesture,
  usePlaybackGate,
} from 'reels-kit';
import type { ReelsFeedRenderItemInfo, ReelVideoHandle } from 'reels-kit';
import { RNVideoAdapter } from 'reels-kit/react-native-video';

interface Reel {
  id: string;
  uri: string;
  caption: string;
}

const REELS: Reel[] = [
  { id: '1', uri: 'https://example.com/a.mp4', caption: 'First reel' },
  { id: '2', uri: 'https://example.com/b.mp4', caption: 'Second reel' },
];

type CellProps = Omit<ReelsFeedRenderItemInfo<Reel>, 'index'> & {
  muted: boolean;
  onToggleMute: () => void;
};

function ReelCell({
  item,
  isActive,
  isFocused,
  isForeground,
  shouldRenderMedia,
  muted,
  onToggleMute,
}: CellProps) {
  // Pause unless this is the active reel, the screen is focused and the app
  // is in the foreground.
  const paused = usePlaybackGate({ isActive, isFocused, isForeground });
  const videoRef = useRef<ReelVideoHandle>(null);

  // Per-cell state that resets when FlashList recycles the cell for a new reel.
  const [time, setTime] = useRecyclingState(0, [item.id]);
  const [duration, setDuration] = useRecyclingState(0, [item.id]);
  const [flash, setFlash] = useRecyclingState(0, [item.id]);

  const gesture = useFeedTapGesture({
    onSingleTap: () => {
      onToggleMute();
      setFlash((n) => n + 1);
    },
  });

  return (
    <View style={styles.cell}>
      <GestureDetector gesture={gesture}>
        <View style={StyleSheet.absoluteFill}>
          {shouldRenderMedia ? (
            <FeedVideoSlot
              ref={videoRef}
              VideoComponent={RNVideoAdapter}
              sourceUri={item.uri}
              paused={paused}
              muted={muted}
              onLoad={(e) => setDuration(e.durationSec)}
              onProgress={(e) => setTime(e.currentTimeSec)}
            />
          ) : null}
        </View>
      </GestureDetector>

      <MuteIndicatorOverlay isMuted={muted} triggerKey={flash} />
      <FeedCaption text={item.caption} style={styles.caption} />
      <ScrubBar
        durationSec={duration}
        currentTimeSec={time}
        onSeek={(seconds) => videoRef.current?.seek(seconds)}
        style={styles.scrubBar}
      />
    </View>
  );
}

export default function App() {
  const [muted, setMuted] = useState(true);
  const toggleMute = useCallback(() => setMuted((m) => !m), []);

  const renderItem = useCallback(
    ({ item, isActive, ...rest }: ReelsFeedRenderItemInfo<Reel>) => (
      <ReelCell
        item={item}
        isActive={isActive}
        isFocused={rest.isFocused}
        isForeground={rest.isForeground}
        shouldRenderMedia={rest.shouldRenderMedia}
        muted={muted}
        onToggleMute={toggleMute}
      />
    ),
    [muted, toggleMute]
  );

  return (
    <GestureHandlerRootView style={styles.root}>
      <ReelsFeed
        data={REELS}
        keyExtractor={(reel) => reel.id}
        renderItem={renderItem}
        extraData={muted}
      />
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: '#000' },
  cell: { flex: 1, backgroundColor: '#000' },
  caption: { position: 'absolute', left: 12, right: 72, bottom: 64 },
  scrubBar: { position: 'absolute', left: 0, right: 0, bottom: 34 },
});
Enter fullscreen mode Exit fullscreen mode

Three ideas carry most of the weight here:

  • renderItem receives isActive, isFocused, isForeground and shouldRenderMedia. Mount the video only while shouldRenderMedia is true. That is what keeps the number of native players small.
  • usePlaybackGate turns those flags into one paused boolean, so a reel can never keep playing behind another screen.
  • Per-cell state (progress, mute flash) uses FlashList's useRecyclingState, so a recycled cell resets for its new reel instead of showing the previous one's state.

If your cell depends on state outside data, like muted above, pass it as extraData so the list re-renders.

Gestures and seeking

useFeedTapGesture gives you a single tap and an optional double tap that do not fire together: the single tap waits briefly to be sure a second tap is not coming. I use the single tap to toggle mute and the double tap to like.

ScrubBar draws the progress and reports where you dragged. What a seek does is up to you, and FeedVideoSlot forwards a ref for exactly that: videoRef.current?.seek(seconds). The bar holds your drag position for a moment after you let go, so it does not snap back to the old position while the video catches up.

Make it yours

Nothing about the layout is fixed. Icons are plain nodes, so use text glyphs, SVG or your icon library:

<FeedActionButton
  icon={<Text style={{ fontSize: 30, color: liked ? '#ff3040' : '#fff' }}>{liked ? '♥' : '♡'}</Text>}
  label="Like"
  count={likes}
  onPress={toggleLike}
/>
Enter fullscreen mode Exit fullscreen mode

FeedAvatar takes imageStyle (so you can make it a rounded square with a ring), and FeedFollowButton has separate styles for the "Follow" and "Following" states. The demo above uses a red ring avatar, a filled follow pill and SVG outline icons for comment and share, all through those props.

Things I learned building it

Run it before you trust the types. Gesture Handler 3 throws at runtime if you combine minDistance with failOffsetX or failOffsetY. TypeScript accepts it, and so did my unit tests. The scrub bar crashed the first time it rendered on a simulator. Dropping minDistance fixed it.

A scrub bar needs a seek. My first version moved the bar when you dragged it, but the video never moved, and the bar snapped back. The gesture was fine. There was simply no way to reach the video's seek(). Forwarding a ref from FeedVideoSlot fixed it.

Progress ticks make a bar look choppy. react-native-video reports its position about four times a second. Rendering that directly makes the bar jump in steps. I animate the fill linearly between updates and snap on drags, loops and backward seeks. To check it, I measured the bar's edge across screenshots: with smoothing off it advanced in exact multiples of one tick, with smoothing on it advanced by uneven amounts, which is what continuous motion looks like.

Measure the container, not the window. On devices with notches, translucent bars or edge-to-edge Android, the window height is not the list height, and a few pixels of difference breaks snapping. reels-kit takes the page height from the container's layout.

Check the boring toolchain details. On React Native 0.85, a space anywhere in the project path broke pod install, because the prebuilt core pod builds a URL from the path. Keep the project in a folder without spaces.

What is tested, and what is not

I tested reels-kit in an empty React Native 0.85 app on the iOS simulator and an Android 16 emulator: paging and snapping, playback through the react-native-video adapter, single tap, double tap, scrubbing and seeking, the mute indicator and the headless components. On Android I also checked the load-error state with retry, and loading more through onEndReached. The pagination and target-item hooks have unit tests, 37 in total.

It has not been tested on physical devices, in right-to-left layouts, with pull to refresh, onItemImpression or the imperative ref. There is no expo-video adapter yet, so Expo apps use the react-native-video adapter in a development build.

This is an early 0.1.x release, and I would rather say so than oversell it.

Contribute

reels-kit is Apache-2.0 and open for contributors. The most useful help right now:

  • Testing on Android and on real devices, and reporting what breaks
  • An expo-video adapter
  • Right-to-left layouts, pull to refresh and the retry flow
  • More tests for the gesture hooks

Try it, and tell me what is missing:

If it saves you an afternoon, a star on the repo helps other people find it.

Top comments (0)