The frame-1 problem in React Native commerce

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: 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.

Revisions

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