Skip to content

Repository files navigation

Nua — Product Listing App

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.


Setup

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 android

Verify:

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

Where each requirement lives

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.


Architecture

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.

Splash screen

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/bootsplash

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

App icon

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.


The three problems worth talking about

1. The search race condition

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 — discard

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

2. Infinite scroll guards

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.

3. Pull-to-refresh vs. pagination

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.


Decisions and trade-offs

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.


DummyJSON quirks encoded deliberately

  • limit=0 means "return everything", which would defeat pagination — limit is clamped to ≥ 1.
  • /products/search echoes limit as the number of rows actually returned, not the page size requested, so hasMore is derived from skip + items.length < total.
  • brand is absent on some categories, so it is optional and rendered conditionally.
  • discountPercentage is a float; the discount rounds once, at the end. Rounding the percentage first gives $8.95 instead of $8.94 on a real row (9.99 @ 10.48%).

Testing

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-native v14 made the API asynchronousrender, renderHook, rerender and fireEvent all 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.


Known limitations and what I would do next

  • No checkout. The cart is the end of the flow; there is no order submission.
  • Cart stock is a snapshot. maxQuantity is 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. Image with no caching layer; expo-image or 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages