A React Native product catalog built with Expo. Browse products, search, view details, and favorite items — with built-in tools to simulate slow networks and large datasets.
- Node.js 18+
- Expo CLI
- Android Studio (for Android) or Xcode (for iOS)
Windows users: Do not place the project inside a OneDrive synced folder. OneDrive stores files as cloud placeholders (reparse points), which causes the Android Gradle build to fail with
not a regular file. Move the project to a local path likeC:\Dev\Products.
# 1. Clone the repo
git clone <your-repo-url>
cd products
# 2. Install dependencies
npm install
# 3. Run on Android
npx expo run:android
- 2-column product grid with discount badges and star ratings
- Sorting using price and rating
- Debounced search (400ms) to avoid unnecessary network requests
- Skeleton loading grid that matches the real layout while data fetches
- Animated product detail drawer with image carousel, pricing, and discount info
- Favorite toggle per card with spring bounce animation
- Performance Mode — fetches 1000 items to stress-test list rendering
- Slow 3G Mode — adds a 1.5s delay to simulate poor network conditions
- Dark/light theme driven by system color scheme
I used Claude as a coding assistant throughout this project. I made all the architectural decisions — Claude helped me implement them and pointed out bugs when I described symptoms. Below is an honest breakdown of how that worked.
I asked for the architecture approach and folder layout
I split the UI into focused, single-responsibility components — ProductCard, ProductRow, ProductDrawer, and CardSkeleton. Each component handles a specific part of the interface, which helps improve rendering performance and loading behavior. This structure also makes it easier to memoize components individually, reduce unnecessary re-renders, and isolate bugs more effectively.
I built a centralized theme with a ThemeContext that reads the system color scheme and exposes a typed theme object to all components. I asked Claude to help wire up the context and the useTheme hook. Every style in the app uses theme tokens (colors, spacing, font sizes, radius) instead of hardcoded values — so dark mode works automatically with zero extra code per component.
I created a commonStyles factory that generates shared styles like card, cardBody, and screen from the theme. Components import this instead of redefining the same styles. I asked Claude to help structure the factory pattern so it accepted the theme object as a parameter.
I chose VirtualizedList over FlatList for explicit control over rendering. FlatList is a wrapper around VirtualizedList that hides the internals — I wanted full control. Since VirtualizedList has no numColumns, I wrote a chunkIntoRows() helper that pairs products into [Product, Product | null] tuples. Claude helped me type it correctly and handle the odd-item edge case.
I designed the skeleton to mirror the exact structure of the real product grid — two cards per row, same spacing, same dimensions. I asked Claude to help implement the CardSkeleton component and wire it into the loading state. The goal was zero layout shift when real data arrives.
I decided early that search should not fire on every keystroke. I asked Claude to implement a useRef-based debounce inside onSearchChange that waits 400ms after the user stops typing before updating debouncedSearch. Only debouncedSearch is in the useEffect dependency array — not the raw search value. This means the network is never hit while the user is still typing.
I added AbortController to both getProducts and searchProducts so any in-flight request is cancelled when the user changes a switch or types a new search term. Without this, a slow response from a previous request could arrive after a newer one and overwrite the correct state. I also made the slow 3G delay respect the abort signal — if the request is cancelled during the artificial delay, the timeout is cleared immediately rather than hanging for 1.5 seconds.
One issue I ran into: the original code used DOMException to signal abort errors, which doesn't exist in React Native's Hermes JS engine. I replaced it with Object.assign(new Error("Aborted"), { name: "AbortError" }) and updated the catch to use e instanceof Error && e.name === "AbortError".
This was the trickiest bug. The loading skeleton was not showing when I toggled a switch or submitted a search — the old product list stayed visible and then jumped directly to new results. After adding render logs, I found the cause: setLoading(true) and setProducts([]) are two separate state updates. React was batching them in a way that left products populated for one extra render after loading became true, so the condition loading ? skeleton : list kept showing the list.
I replaced both with a single useReducer:
FETCH_START → { loading: true, products: [] } // one render, skeleton guaranteed
FETCH_SUCCESS → { loading: false, products: data } // one render, list appears
FETCH_ERROR → { loading: false, products: [] } // one render, empty state
Because the reducer updates both fields atomically, there is no render where loading=true and products still has old data. The skeleton now shows immediately every time.
React Native logged: VirtualizedList: You have a large list that is slow to update. I traced it to renderItem being a new function reference on every render, which broke memoization on ProductCard and caused the entire list to re-render on any state change.
I fixed this in three steps:
- Wrapped
ProductCardinReact.memoso it skips re-renders when props are identical - Extracted a
ProductRowcomponent that creates stableonClickcallbacks withuseCallback, so memo is never broken by a new function reference - Memoized the
rowsandskeletonsarrays withuseMemoso they are not recomputed on every render
On top of AbortController, I added an isCancelled boolean flag in every useEffect. The cleanup function sets it to true before aborting. All dispatch calls inside the async block are guarded by if (!isCancelled). This is a second layer of protection — even if an abort is missed or a response slips through, the dispatch is blocked and no stale state update fires after unmount.
I used Android Studio to test the application’s performance, particularly focusing on memory usage, rendering speed, and responsiveness to user interactions. I monitored how the app behaved when loading large datasets, such as when Performance Mode loads 1000+ items, ensuring that scrolling remained smooth without frame drops or UI freezes.
I also tested rapid user interactions, including quickly toggling favorites, typing in the search field, and repeatedly opening and closing the product drawer, to ensure the app handled aggressive input without race conditions or excessive re-renders. Additionally, I tested navigation and backgrounding scenarios by repeatedly opening detail views and sending the app to the background, then returning to verify that the state was preserved and no memory leaks or crashes occurred.
Only items visible on screen are rendered, plus a small overscan buffer. removeClippedSubviews={true} detaches offscreen items from the native view hierarchy entirely, reducing memory pressure during fast scrolling.
React.memo on ProductCard and stable callbacks via useCallback in ProductRow ensure the list only re-renders changed rows, not the entire list on every state update.
At most one fetch fires per 400ms idle period. Without this, every character typed would trigger an abort + new fetch, flood the network, and cause constant flickering.
loading and products always transition together in a single render. No intermediate render can show loading=true with stale products or loading=false before products are set.
In-flight requests are cancelled immediately when dependencies change. Combined with the isCancelled guard, there are no stale state updates, no dangling async callbacks, and no memory leaks from unmounted components.
Images are served from dummyjson.com's CDN and loaded with React Native's built-in Image component using resizeMode="contain". The CDN handles compression and resizing server-side.
For a production app I would replace Image with expo-image, which adds:
- Blurhash placeholder while loading (no blank space while image loads)
- Automatic memory and disk caching
- Faster decoding via the native image pipeline
Favorites reset on reload — Favorite state is local to each ProductCard instance and is not persisted. The fix is to store favorited product IDs in Zustand and persist with zustand/middleware/persist backed by AsyncStorage.
No pagination — The app loads a fixed set of items (20 normally, 1000 in Performance Mode). There is no infinite scroll. The fix is to track a skip offset in state, increment it on onEndReached, and append new pages to the existing product array.
Performance Mode is artificial — Loading 1000 items at once exists only to stress-test the list renderer. Real apps use pagination and never load more than one page at a time.
app/
_layout.tsx # Root layout: GestureHandlerRootView + ThemeProvider + Stack
index.tsx # Home screen: VirtualizedList, search, toggles, drawer
components/
home/
ProductCard.tsx # Card with image, price, rating, discount badge, favorite toggle
ProductRow.tsx # Memoized 2-column row wrapper for VirtualizedList
ProductDrawer.tsx # Animated full-screen detail sheet with image carousel
CardSkeleton.tsx # Skeleton placeholder matching the card dimensions
SortControl.tsx # Sorting component using price and rating with clear function
api/
productsApi.ts # getProducts + searchProducts with AbortController and slow3G delay
store/
performanceStore.ts # Zustand store: performanceMode and slow3G toggle state
styles/
common-style.ts # Shared styles: screen, card, cardBody — themed via factory function
theme/
ThemeContext.tsx # ThemeProvider + useTheme hook
colors.ts # Color palette for light and dark
index.ts # Theme object: colors, spacing, fontSize, radius
utils/
productsStateReducer.ts # Handles product state management (loading, error, data)
Troubleshooting Common errors encountered during Android development setup and how to fix them.
- OneDrive File Placeholder Error (react-native-screens codegen)
Error:
Execution failed for task ':react-native-screens:generateCodegenSchemaFromJavaScript'.
java.io.IOException: Cannot snapshot C:\Users...\node_modules\react-native-screens\src\fabric\bottom-tabs\BottomTabsNativeComponent.ts: not a regular file Cause: The project is inside an OneDrive folder. OneDrive's "Files On-Demand" feature creates placeholder files that aren't real files on disk. Gradle's incremental build system cannot snapshot these placeholders. Fix: Move the project outside of OneDrive to a path like C:\dev: bashxcopy /E /I "C:\Users<you>\OneDrive\Documents\Programming\Products" "C:\dev\Products" cd C:\dev\Products npm install cd android && gradlew clean && cd .. npx react-native run-android
Note: Keep all React Native / Node.js projects outside of OneDrive, Dropbox, or any cloud sync folder. These tools don't handle node_modules well (100k+ small files) and will cause recurring issues.
- Android SDK Location Not Found
Error:
Execution failed for task ':app:processDebugMainManifest'.
com.android.builder.errors.EvalIssueException: SDK location not found. Define a valid SDK location with an ANDROID_HOME environment variable or by setting the sdk.dir path in your project's local.properties file. Cause: Gradle cannot find the Android SDK because neither local.properties nor the ANDROID_HOME environment variable is configured. Fix: Create or edit android/local.properties and add the path to your SDK: sdk.dir=C:\Users\\AppData\Local\Android\Sdk
Note the escaped backslashes — required in .properties files.
To find your SDK path, open Android Studio → File → Settings → search "Android SDK" and copy the path shown. Alternatively, set ANDROID_HOME as a permanent system environment variable:
Search "Environment Variables" in the Start menu Under User variables, click New
Name: ANDROID_HOME Value: C:\Users<you>\AppData\Local\Android\Sdk
Edit the Path variable and add %ANDROID_HOME%\platform-tools Restart your terminal
- AndroidManifest Warnings (usesCleartextTraffic / FileSystemFileProvider) Warnings: application@android:usesCleartextTraffic was tagged at AndroidManifest.xml:6 to replace other declarations but no other declaration present
provider#expo.modules.filesystem.FileSystemFileProvider@android:authorities was tagged at AndroidManifest.xml:0 to replace other declarations but no other declaration present Cause: The debug manifest uses tools:replace to override declarations that don't exist in the main manifest. These are harmless warnings generated by Expo/React Native boilerplate. Fix: These warnings do not affect your build or app behavior and can be safely ignored. If you want to clean them up, open android/app/src/debug/AndroidManifest.xml and remove the tools:replace attributes: xml
<provider android:authorities="${applicationId}.FileSystemFileProvider" ... />
-
Kotlin Compilation Error — Unresolved Reference 'R' and 'BuildConfig' Error: e: .../MainActivity.kt:18:14 Unresolved reference 'R'. e: .../MainActivity.kt:35:11 Unresolved reference 'BuildConfig'. e: .../MainApplication.kt:32:60 Unresolved reference 'BuildConfig'. Cause: The R and BuildConfig classes are auto-generated during the build. They fail to generate when the namespace is missing from android/app/build.gradle, or when there is a package name mismatch across config files. Fix: Ensure namespace is declared in android/app/build.gradle and matches your package name everywhere: gradleandroid { namespace "com.charlesamiel1.Products" // required compileSdkVersion 34
defaultConfig { applicationId "com.charlesamiel1.Products" }} The package name must be identical in all four places:
android/app/build.gradle → namespace and applicationId android/app/src/main/AndroidManifest.xml → package attribute (if present) MainActivity.kt → package declaration at top of file MainApplication.kt → package declaration at top of file
After fixing, clean and rebuild: bashcd android && gradlew clean && cd .. npx react-native run-android
- Java Compilation Error — Package Does Not Exist (Autolinking Mismatch)
Error:
Execution failed for task ':app:compileDebugJavaWithJavac'.
error: package com.products does not exist if (com.products.BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { Cause: The autolinking system generates code referencing com.products (derived from the slug in app.json), but the actual Android package is com.charlesamiel1.Products. This happens when app.json is missing an explicit android.package field and Expo falls back to generating one from the slug. Fix: Add the android.package field to app.json: json{ "expo": { "android": { "package": "com.charlesamiel1.Products" }, "ios": { "bundleIdentifier": "com.charlesamiel1.Products" } } } Then regenerate the native Android folder: bash# Back up any custom android changes first xcopy /E /I android android_backup
npx expo prebuild --clean
npx expo run:android
Warning: --clean will overwrite the entire android folder. Back it up first if you have manual customizations.