DEV Community

Cover image for I rebuilt my 2021 React Native image gallery in 2026 — every library I used is dead
Nader Alfakesh
Nader Alfakesh

Posted on

I rebuilt my 2021 React Native image gallery in 2026 — every library I used is dead

Five years ago I wrote a tutorial about handling images in React Native — loading, caching, viewing, and pinch-to-zoom. I recently went back to it, and it aged the way milk ages. Not because the advice was wrong at the time, but because every single library I built on is now dead.

So instead of updating the old post, I archived it and rebuilt the whole demo from scratch, the way I would write it in 2026. This is the story of what died, what replaced it, and the one part that's still genuinely worth building yourself.

What the 2021 version was

The original demo was a small gallery app: a reusable image component with a loading spinner, avatar and card variants built on top of it, and a full-screen viewer with pinch-to-zoom. The stack: bare React Native 0.64, react-native-fast-image for caching, react-native-image-pan-zoom for the viewer, and an Ignite boilerplate around it.

The autopsy

2021 2026
Bare RN 0.64 + boilerplate Expo SDK 57, expo-router, React Compiler
react-native-fast-image + hand-rolled spinner expo-image (caching, placeholder, transition)
react-native-image-pan-zoom (abandoned) Custom viewer: Reanimated 4 + Gesture Handler 2
Manual image measuring for the zoom library Gestures own the transform — no measuring
TouchableOpacity, Dimensions.get Pressable, useWindowDimensions
apisauce + API layer classes fetch + AbortController

react-native-fast-image hasn't seen a release since 2021 and doesn't play well with the New Architecture, which is now the default. react-native-image-pan-zoom was abandoned even earlier — it predates modern Reanimated. And the upgrade path from RN 0.64 crosses so many breaking changes (New Architecture, the death of unimodules, React 17 → 19) that upgrading in place would have been slower than starting fresh. If your project is five years old, rebuild beats upgrade.

Part 1: the image component became boring — and that's the lesson

In 2021, a "good" image component was real work: wrap FastImage, track loading state, render an ActivityIndicator, expose props for all of it. Today the equivalent is embarrassingly small:

const AppImage = ({ url, containerStyle, placeholderColor, onPress, style, ...rest }: Props) => {
  return (
    <Pressable style={[styles.base, containerStyle]} onPress={onPress} disabled={!onPress}>
      <Image
        style={[styles.base, !!placeholderColor && { backgroundColor: placeholderColor }, style]}
        source={{ uri: url }}
        contentFit="cover"
        transition={200}
        cachePolicy="memory-disk"
        {...rest}
      />
    </Pressable>
  );
};
Enter fullscreen mode Exit fullscreen mode

expo-image ships everything my old component existed to add: memory + disk caching, placeholders, fade-in transitions, priorities, and recyclingKey for list performance.

One trick I like: the Pexels API returns an avg_color for every photo. Feed it into that placeholderColor prop and every image loads over a background that already matches it — a poor man's blurhash for free:

const mapPhoto = (image: PexelsPhotoResource): Photo => ({
  id: String(image.id),
  url: image.src.medium,
  urlLarge: image.src.portrait,
  title: image.photographer,
  avgColor: image.avg_color,
});
Enter fullscreen mode Exit fullscreen mode

The takeaway stings a little: most of my 2021 article was me wrapping features the platform was about to absorb. Don't build infrastructure around a gap the ecosystem is actively closing.

Part 2: the viewer is still worth building — and now you can actually own it

The full-screen viewer is the part that got more interesting. In 2021 I delegated everything to a library and had to manually measure the image to feed it. In 2026, Gesture Handler 2 + Reanimated 4 make it reasonable to own the whole thing in a couple hundred lines, running entirely on the UI thread.

The core is three gestures. Pinch, zooming around the focal point so the pixels under your fingers stay put:

const pinch = Gesture.Pinch()
  .onStart(() => {
    savedScale.set(scale.get());
    savedTranslateX.set(translateX.get());
    savedTranslateY.set(translateY.get());
    // A second finger means the user is zooming, not dismissing.
    dismissY.set(withTiming(0, { duration: 100 }));
  })
  .onUpdate((event) => {
    // Follow the fingers below rest scale too — clamping at 1 makes the
    // gesture feel dead the moment you reverse direction mid-pinch.
    const nextScale = clamp(savedScale.get() * event.scale, PINCH_MIN_SCALE, MAX_SCALE);
    scale.set(nextScale);

    // Keep the point under the user's fingers fixed while scaling:
    // t' = f - (f - t0) * (s / s0), with f measured from the screen center.
    const focalX = event.focalX - width / 2;
    const focalY = event.focalY - height / 2;
    const ratio = nextScale / savedScale.get();
    translateX.set(focalX - (focalX - savedTranslateX.get()) * ratio);
    translateY.set(focalY - (focalY - savedTranslateY.get()) * ratio);
  });
Enter fullscreen mode Exit fullscreen mode

Pan does double duty: when zoomed in it moves the image (clamped so you never pan into empty space), and at rest a vertical drag becomes swipe-down-to-dismiss with the backdrop fading out — the pattern every photo app has trained users to expect:

const pan = Gesture.Pan()
  .maxPointers(1)
  .onChange((event) => {
    if (scale.get() > MIN_SCALE) {
      translateX.set(translateX.get() + event.changeX);
      translateY.set(translateY.get() + event.changeY);
    } else {
      dismissY.set(event.translationY);
    }
  })
  .onEnd((_event, success) => {
    if (scale.get() > MIN_SCALE) {
      const s = scale.get();
      translateX.set(withSpring(clampToBounds(translateX.get(), width, s)));
      translateY.set(withSpring(clampToBounds(translateY.get(), height, s)));
    } else if (success && Math.abs(dismissY.get()) > DISMISS_THRESHOLD) {
      runOnJS(onClose)();
    } else {
      dismissY.set(withSpring(0));
    }
  });
Enter fullscreen mode Exit fullscreen mode

Add a double-tap that zooms toward the tap point and a two-finger rotation that slots in the same way, compose them, and that's the whole viewer:

const gesture = Gesture.Race(doubleTap, Gesture.Simultaneous(pinch, rotate, pan));
Enter fullscreen mode Exit fullscreen mode

The part that made me smile: my 2021 code had a whole calculateImageSize function to measure the image for the zoom library. The new viewer doesn't measure anything. contentFit="contain" handles the layout and the gestures own the transform.

Two modern details worth knowing:

  • React Compiler is on by default in new Expo projects, and it can't track sharedValue.value property access — use .get() and .set() instead. Every snippet above does.
  • Everything runs as worklets on the UI thread. The JS thread can be busy parsing a response and the pinch stays at 60fps.

Small things that quietly got better

  • Pressable replaced TouchableOpacity, useWindowDimensions replaced Dimensions.get (it actually updates on rotation).
  • Flexbox gap in list contentContainerStyle replaced the old style={index && styles.item} margin hack.
  • Plain fetch + AbortController replaced my apisauce layer — and cancelling in-flight requests on unmount is now three lines.
  • EXPO_PUBLIC_* env vars replaced the git-ignored env.js file. The demo also falls back to Lorem Picsum when there's no API key, so npm install && npx expo start just works.

What five years taught me about dependencies

Looking at which parts of the 2021 stack survived, the pattern is clear:

  • Platform-adjacent libraries survived and absorbed everything — Expo, Reanimated, Gesture Handler. Backed by teams, aligned with where the platform was going.
  • Single-purpose UI wrappers died — fast-image, image-pan-zoom. Maintained by individuals, built to patch gaps the platform eventually closed.

So my rule now: take the platform-adjacent dependency, and for the part that is your feature — in this case, the gesture feel of the viewer — own the code. It's less code than you think, and it's the part worth writing an article about five years from now.

The full project is on GitHub — components, the viewer, and the demo app:

GitHub logo naderalfakesh / image-toolset-modern

Modern React Native image handling — expo-image, gesture-driven viewer with Reanimated 4, Expo SDK 57. Rebuild of my 2021 demo.

ImageToolSet banner

Image Toolset — Modern

📝 Read the article: I rebuilt my 2021 React Native image gallery in 2026 — every library I used is dead

A modern rebuild of ImageToolSet, the demo project behind my 2021 dev.to article about handling images in React Native. Five years later most of that article's stack is gone, so this repo rebuilds the same component set the way you would write it today.

What changed since 2021

2021 Now
Bare RN 0.64 + Ignite boilerplate Expo SDK 57, expo-router, React Compiler
react-native-fast-image + hand-rolled spinner expo-image (caching, placeholder, transition)
react-native-image-pan-zoom (abandoned) Custom viewer: Reanimated 4 + Gesture Handler 2
Manual image size measuring for the zoom library Gestures own the transform — no measuring
TouchableOpacity, Dimensions.get Pressable, useWindowDimensions
apisauce + class-era API layer fetch + AbortController
API key in a git-ignored env.js EXPO_PUBLIC_PEXELS_API_KEY in .env

Screenshots

Home Viewer Pinch & double-tap

P.S. The repo keeps moving — since publishing it also gained full-card press feedback, pinch zoom-out overshoot, two-finger rotation, and a shared transition that morphs the tapped thumbnail into the viewer. The snippets above remain the core; the repo always has the current code.

The 2021 repo is archived for the curious, with a pointer to the new one. Some code is better left as a time capsule.

Top comments (0)