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>
);
};
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,
});
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);
});
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));
}
});
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));
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.valueproperty 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
-
PressablereplacedTouchableOpacity,useWindowDimensionsreplacedDimensions.get(it actually updates on rotation). - Flexbox
gapin listcontentContainerStylereplaced the oldstyle={index && styles.item}margin hack. - Plain
fetch+AbortControllerreplaced myapisaucelayer — and cancelling in-flight requests on unmount is now three lines. -
EXPO_PUBLIC_*env vars replaced the git-ignoredenv.jsfile. The demo also falls back to Lorem Picsum when there's no API key, sonpm install && npx expo startjust 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:
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.
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)