# The frame-1 problem in React Native commerce

A native product page feels broken when its first rendered frame differs from its settled one: an empty selector that pops in, a hero image that swaps, a colour that changes under the thumb. On a retailer's React Native app I fixed that in seven commits over one day — prefetch during the push animation, synchronous cache reads, render-time derived selection, a hero URL that hits the image cache, and the tapped image's colour as the seed.

Published: 2026-09-21
Canonical: https://umar.codes/frame-1-react-native-commerce

## Revisions

- 2026-09-05 — Created; body written from the feature's spec, plan, and the seven commits of 2026-07-05.
- 2026-09-21 — Published. Opening cut from 61 words to 60 to meet the site's direct-answer limit.

---

A product page in a native app has one frame to be right. If the first frame
shows an empty selector, a placeholder hero, or a colour that changes,
the page reads as broken even when it settles 200ms later. On a retailer's
React Native app, getting a variant selector right on frame 1 took seven
commits in one day.

## What the variant selector had to do

The app is a React Native 0.86 storefront over Shopify's Storefront API, with
Apollo Client for data. The product page had one image and a buy button wired
to the first variant. The feature was the standard commerce selector: pick a
colour, pick a size, see a carousel of that colour's images, and have Add to
cart and the express-checkout button target the matching variant.

The store data made it non-trivial. A typical knit has 13 colours by 6 sizes,
78 variants; the largest products carry 150 variants and 135 images, so the
detail query pages at the Storefront maximum of 250. List screens fetch 48
products at a time with one variant and one image each, because fattening
those queries with full variant data would balloon the payload. So the
product page gets a lightweight product from route params and has to fetch
the rest itself. The design was written [spec-first](/spec-driven-development):
a `FetchProduct` query on mount, selection state in the screen, pure helpers
for size fallback and variant lookup, two presentational components.

That design shipped in five commits and worked. It also flickered, and the
rest of the day was finding out why.

## The push animation is a budget, not a delay

The first version fired the detail query from a `useEffect` after the screen
mounted. The stack navigator's push animation takes roughly 350ms, and the
fetch took longer than that, so the selectors popped in after the page was
already on screen. The fix was to treat the animation as a budget: on list
tap, before navigating, run the exact detail query through the Apollo
client — same document, same variables — so the cache is warm by the time the
screen mounts. Prefetch is best-effort and swallows errors; the screen's own
query handles the real failure path.

That removed the pop-in on a fast connection and did nothing for frame 1,
which is when I learned the difference between a warm cache and a
synchronous read.

## A warm cache still renders empty if you read it in an effect

With the cache warm, the screen still showed an empty selector for its first
render or two. Two causes, both in how the data was read. The query was a
*lazy* one, executed in an effect — so render 1 had no data by construction,
regardless of the cache. And the initial colour and size were seeded in a
*second* effect that ran after the data arrived — so even the render that
had data had no selection. Two effects, two extra frames, a visible layout
jump.

The fix removed both effects. The screen uses a non-lazy `useQuery`, whose
default cache-first policy returns synchronously during render when the
entry exists. And selection became a derived value instead of state:
`activeColor = selectedColor ?? initialColor`, where the initial colour is
computed from the data in render and `selectedColor` is null until the user
taps something. Same for size, through the `pickSize` helper. A warm cache
now renders fully populated and fully selected on the first frame. This is
the general rule the rest of the day kept re-proving: anything that must be
right on frame 1 has to be derivable during render, not arrived at by an
effect.

## Images have their own cache, and it keys on the URL

Data on frame 1 left the images behind. The list screen showed a 150px
thumbnail; the product page asked for the original CDN image, which is a
different URL and therefore a cold miss in React Native's image cache,
rendered as a white flash. Three changes. The detail query's images are
right-sized at the CDN, 1200 by 1600, instead of full originals. The list
query fetches a second image URL with *that same transform*, so the product
page's first frame can render the list's hero through the same URL the
detail query will return — a cache hit, because the key is the URL. And the
list tap prefetches the hero pixels alongside the data.

The transform now appears in three places in the query file and must stay
byte-identical at all three, which is exactly the kind of lockstep that
survives on goodwill. It got a comment at each site naming the other two,
because one edit to any of them silently reintroduces the flash.

Colour switches got the same treatment: each colour's hero image is
prefetched when the page loads, and the rest of a colour's images on tap,
bounded to six per burst so a 135-image product doesn't fire 135 requests.

## Frame 1 needs the options before the detail query lands

Even with a synchronous cache read, the first visit to a product on a slow
connection has no detail data yet. The screen used to render nothing in the
selector's place and then insert it, shifting everything below. Two fixes
here. The list query grew the product's option lists — colour names, size
names, a few hundred bytes per product — so the selector can render real
chips and a real size bar on frame 1 from route params, with sizes shown as
available until the detail query says otherwise. And the carousel's dot row
reserves its 6pt of height even when there is one image, so the row doesn't
appear later and push the title down. Where data genuinely isn't there yet, a
pulsing skeleton holds the selector's space, and the carousel's image
background is a brand tone rather than white.

## The colour on screen has to be the colour you tapped

The last visible swap was the subtlest. The initial colour was
`colorOptions[0]`, but the list thumbnail is the product's *first variant's*
image, and those are not the same colourway for every product. The page would
seed the carousel with the thumbnail, then the detail data would arrive,
colour filtering would kick in on the first option, and the hero would swap
to a different colour under the user's thumb. The fix derives the initial
colour from the seeded image itself — matching its alt text and URL token
against the option list with the same matcher the carousel uses — so
filtering starts on the colour already on screen. The list's single variant
now carries its selected options too, so the pre-detail buy button can't
offer a variant of a different colour than the one displayed.

## What the review caught, and the baseline

A final review pass added four guards: a size tap before detail data arrives
that deviates from the route variant's size disables buying rather than
adding the wrong variant; a detail-query error hides the selectors and falls
back to the pre-feature buy path; prefetch bursts are bounded; and an
off-by-one in the carousel's dot indicator on colour change was fixed. The
pure helpers gained tests for each guard.

The day's numbers: 18 tests before the feature, 47 after, across five suites,
with the two new pure modules — the colour-to-image matcher and the variant
selection helpers — carrying 29 of them. Type-check clean, lint held at the
same four pre-existing warnings. Six of the twelve commits that day were
frame-1 fixes to a feature that already worked, and the seventh was the
review pass; every one of them was a case of something being correct
eventually rather than immediately.

The list I now carry into any native screen that arrives via a push: prefetch
during the animation; read the cache synchronously; derive the initial state
in render; make the first frame's image URL the same one the settled frame
will use; seed from what the user tapped, not from index zero; and reserve
the height of anything that can appear later.
