A React Native product browser built against the DummyJSON API: paginated listing with infinite scroll, debounced search, product detail with an image carousel, a persisted cart, a WebView return-policy screen, and a mock analytics layer.
Built with the React Native CLI (bare workflow), TypeScript, React Navigation and Zustand.
Prerequisites: Node ≥ 22.11, Watchman, Xcode + CocoaPods for iOS, Android Studio with an SDK and emulator for Android.
npm install
cd ios && bundle install && bundle exec pod install && cd ..Run:
npm start # Metro
npm run ios # or: npm run androidVerify:
npm test # 47 tests across 8 suites
npm run typecheck # tsc --noEmit
npm run lint # eslint| Script | Purpose |
|---|---|
start / ios / android |
Metro and the platform builds |
test / test:watch |
Jest |
typecheck |
tsc --noEmit |
lint / lint:fix |
ESLint |
format |
Prettier |
| Requirement | Implementation |
|---|---|
| Paginated listing, infinite scroll | src/screens/ProductList/hooks/useProductFeed.ts |
| Debounced search | src/hooks/useDebouncedValue.ts + the same hook |
| Product detail, image carousel | src/screens/ProductDetail/ |
| Discounted price | src/utils/pricing.ts |
| Cart (Zustand) | src/store/cartStore.ts |
| AsyncStorage persistence | persist middleware in the same file |
| WebView return policy | src/screens/ReturnPolicy/ReturnPolicyScreen.tsx |
| Analytics events | src/services/analytics/ |
| Retry with exponential backoff | src/api/retry.ts, src/api/httpClient.ts |
| Pull-to-refresh | refresh mode in useProductFeed.ts |
| Race-condition test | src/screens/ProductList/hooks/__tests__/useProductFeed.race.test.tsx |
| Persisted dark mode | src/store/themeStore.ts, src/theme/ThemeProvider.tsx |
All four bonus items are implemented.
assets/ Brand logo and the generated splash images
src/
api/ HTTP client, retry policy, error taxonomy, products repository
components/ Shared UI — common/ (primitives), cart/, theme/
config/ Base URL, timeouts, page size, debounce window
hooks/ Cross-feature hooks
navigation/ Typed native stack
screens/ One folder per screen, with its own components/ and hooks/
services/ Analytics (transports, event map, AppState tracking)
store/ Zustand stores (cart, theme)
test/ Test factories
theme/ Design tokens and the theme provider
types/ Domain types
utils/ Pricing, dates, abortable sleep
Screens own their private components and hooks; anything used by two screens moves up to
src/components or src/hooks. Imports use a @/* alias, wired in three places that must
agree: babel.config.js (bundler), tsconfig.json paths (type checker), and
jest.config.js moduleNameMapper (tests).
The theme exposes semantic tokens (textSecondary, brand, surfaceMuted) rather than
raw colours, so light/dark is resolved once in ThemeProvider and no component knows which
scheme won. useThemedStyles memoises theme-dependent stylesheets so they rebuild only when
the theme actually changes.
react-native-bootsplash renders the brand mark on #E75651, the logo's own background
colour, sampled from the supplied artwork. Assets are generated from assets/logo-mark.png
(the wordmark with the coral keyed out into an alpha channel, so it composites cleanly at any
size) and regenerated with:
npx react-native-bootsplash generate assets/logo-mark.png \
--platforms=android,ios --background="#E75651" --logo-width=134 \
--assets-output=assets/bootsplashThe 134dp width is deliberate: Android's splash spec caps the icon, and bootsplash
silently skips Android asset generation entirely above 192dp (and warns about cropping
above 134dp), leaving styles.xml pointing at a drawable that does not exist.
Generated from the same artwork by npm run icons
(scripts/generate-app-icon.js), which produces the iOS 1024 master, legacy
launcher icons at five densities, adaptive foreground and monochrome layers for
Android 8+/13+, and the mipmap-anydpi-v26 XML.
The icon uses the "n" lifted from the wordmark, not the full logo: launchers mask adaptive icons to a 66dp circle, and a 3:1 wordmark has to shrink so far to fit that it stops being legible. The glyph is located by scanning the artwork for the first run of columns containing ink, so replacing the source does not silently produce a mis-cropped icon.
Two details the script exists to get right: the iOS master has its alpha channel
removed (flatten() composites onto the background but leaves the channel in
place, and the App Store rejects icons that have one), and the monochrome layer
is produced by masking a black canvas rather than tint(), which operates in LAB
and would leave white artwork white.
The splash is hidden not on mount but once the theme store has hydrated
(src/hooks/useHideBootSplash.ts). Since ThemeProvider withholds its children until the
stored appearance preference is read, hiding on mount would expose that gap as a blank frame;
waiting means the first frame the user sees is already in the correct scheme.
One native note: wiring the splash also let MainActivity adopt
RNScreensFragmentFactory (react-native-screens ≥ 4.16). That supersedes the older
super.onCreate(null) workaround, which avoided a fragment-restoration crash by discarding
saved instance state wholesale — state is now passed through and restored properly.
Debouncing is a bandwidth optimisation, not a correctness fix. It reduces how many requests are sent, but the last two can still overlap, and on a slow connection the earlier one can resolve last — so the list ends up showing results for a prefix of what the user typed.
The fix is a monotonic request id stamped on every request in useProductFeed. A response
may touch state only while its id is still the newest:
const requestId = (requestIdRef.current += 1);
// ...
if (requestId !== requestIdRef.current) return; // superseded — discardAbortController is layered on top as an optimisation (it saves the bandwidth and settles
things sooner), but the guard is what makes it correct. The test for this deliberately
mocks at the repository level so cancellation has no effect and the stale promise genuinely
resolves — otherwise the test would pass even with the guard removed.
The query is also captured at request time, so a page-2 request keeps paging the query it was issued for rather than appending onto whatever the user has since typed.
FlatList fires onEndReached more than once per tick. A guard held in React state reads
the same pre-update isLoadingMore === false in both callbacks and fires duplicate page
requests, which silently skips or double-loads a page. So scheduling lives in refs
(in-flight flag, next skip, hasMore) and React state is only for rendering.
A failed page keeps its products on screen — loadMoreError is a separate field from
error precisely so a failed page 4 does not replace 60 rendered products with a
full-screen error. After a failure a ref blocks further automatic loads, because a user
parked at the bottom of the list would otherwise re-trigger onEndReached and hammer a
failing server; recovery requires an explicit tap.
Pages are merged by id rather than concatenated, since offset pagination can return a row
twice if the collection shifts, and duplicate keys break FlatList recycling.
The naive version sets skip = 0 and refetches without cancelling the outstanding page, so
an in-flight page 3 lands on top of a freshly reloaded page 1 and the offset is corrupted.
Refresh here is a third mode alongside initial and more: it preempts what is in flight,
and the same request-id check discards the stale page even if its response is already on the
wire. onEndReached during a refresh is a no-op, and nextSkip is rebuilt from the
refreshed page rather than left where paging had reached.
Zustand over Redux or Context. Context + useReducer is dependency-free but re-renders
every consumer on any cart change — the header badge, the add-to-cart bar and the list would
all re-render when a quantity changes, and the fixes (splitting contexts, memoising the
value) are manual bookkeeping you must keep getting right. Redux Toolkit solves that but
brings slices, a provider and redux-persist for what is one array of line items. Zustand
gives selector-based subscriptions with no provider and ships persist in the box.
That property is only worth having if the store exploits it, so every cart hook returns a
primitive (useCartItemCount() returns a number). Zustand compares the selected slice with
Object.is, so the badge re-renders only when the count actually changes. Selecting an
object or a derived array would allocate a new reference on every write and re-render all its
consumers — the usual way this optimisation is silently discarded.
Cart lines snapshot the price at add time rather than re-deriving from live catalogue data. The price the user agreed to should not change under them, and the cart stays fully renderable offline without refetching every product.
The list requests a field projection. ProductListItem is derived from the same
PRODUCT_LIST_FIELDS tuple that builds the select query string, so the request and the
type cannot drift. Detail fetches the full product by id.
A typed error taxonomy, not error strings. ApiError carries a kind
(aborted | timeout | network | client | server | parse | unknown), which is what lets the UI
branch sensibly — a retry affordance for network, an empty state for a 404, and silent
handling for aborted, which is routine when a search is superseded and must never render as
a failure. describeError is the single place a failure becomes user-facing copy, and its
switch is exhaustive so a new failure mode is a compile error rather than a silent fallback.
No NetInfo. Link state answers "is Wi-Fi connected", which is the wrong question — a device behind a captive portal is "online" while every request fails. Since failures are already classified, the offline banner reflects actual API reachability, with no extra native module. If true link state is wanted it is a small addition.
ThemeProvider holds the first paint until the stored appearance preference is read.
AsyncStorage resolves a frame or two after mount, so painting with the system scheme and
correcting afterwards is a visible light→dark flash on every cold start for anyone who
overrode the default.
A hasHydrated flag on the cart. Without it the UI cannot distinguish "cart is empty"
from "cart has not loaded yet" and flashes the empty state on every launch. A corrupt payload
logs a warning and continues with an empty cart rather than blocking app start.
Analytics is typed at the call site. AnalyticsEventMap declares each event's payload and
track<K>(name, params) binds them, so a renamed field or a missing productId fails the
build instead of quietly becoming a gap in a dashboard. Transports are a list, so a real SDK
is appended in one place; every send is wrapped so instrumentation can never take a user
flow down with it.
limit=0means "return everything", which would defeat pagination — limit is clamped to ≥ 1./products/searchechoeslimitas the number of rows actually returned, not the page size requested, sohasMoreis derived fromskip + items.length < total.brandis absent on some categories, so it is optional and rendered conditionally.discountPercentageis a float; the discount rounds once, at the end. Rounding the percentage first gives$8.95instead of$8.94on a real row (9.99 @ 10.48%).
47 tests across 8 suites — npm test.
The headline is useProductFeed.race.test.tsx, which resolves responses out of order to
prove a stale search cannot overwrite newer results. Alongside it: pagination guards
(duplicate onEndReached, end-of-list, page failure, refresh), the list screen's loading and
error states, the search bar, discount maths, the retry schedule and Retry-After parsing,
cart clamping and a persist/rehydrate round trip, and the theme hydration gate.
Two environment notes for anyone extending the suite:
@testing-library/react-nativev14 made the API asynchronous —render,renderHook,rerenderandfireEventall return Promises.- Test fixtures must give each page distinct ids (
productPage(titles, { startId })), otherwise the merge-by-id deduplicates them and a correct implementation looks broken.
Mounting the full navigator under Jest leaves Platform undefined inside the react-native
module — an import-time issue in react-native-screens. Screens are therefore tested
directly, which is better coverage than a smoke test anyway.
- No checkout. The cart is the end of the flow; there is no order submission.
- Cart stock is a snapshot.
maxQuantityis the stock seen when the item was added and is never revalidated. A real app would re-check at checkout. - The theme gate costs a frame. Correct, but a synchronous store (MMKV) would remove the wait entirely rather than hiding it.
- No response caching. Navigating back to the list refetches page one. A query cache (TanStack Query, or a small in-memory layer) would make back-navigation instant and give stale-while-revalidate for free — this is the first thing I would add with more time.
- Analytics is fire-and-forget. No offline queue or batching; events logged while offline are lost. A real SDK would persist and flush them.
- Images are unoptimised.
Imagewith no caching layer;expo-imageor FastImage would improve scroll performance on long lists. - Accessibility is partial. Roles and labels are set on interactive elements, but the app has not been tested end-to-end with a screen reader or at large font scales.
- Currency is hardcoded to USD, matching the API. Real localisation would come from the device locale and a server-provided currency.