A React Native mobile app for browsing, searching, and favouriting products from DummyJSON API. Supports English (LTR) and Arabic (RTL) with full layout mirroring, dark mode, and offline-persistent favourites.
- Node.js >= 22
- Xcode (iOS) / Android Studio (Android)
- CocoaPods (iOS)
# 1. Install JS dependencies
npm install
# 2. iOS — install native pods
cd ios && pod install && cd ..
# 3. Start Metro bundler
npm start
# 4a. Run on iOS
npm run ios
# 4b. Run on Android
npm run android# iOS Simulator
xcrun simctl openurl booted "productexplorer://products/1"
# Android
adb shell am start -W -a android.intent.action.VIEW -d "productexplorer://products/1"npm testsrc/
├── api/ # All API calls and response types — no fetch() in components
│ ├── client.ts # Base fetch wrapper with APIError class
│ ├── products.ts # Product/category endpoint functions
│ └── types.ts # Full Product, Category, Response interfaces (strict, no any)
├── components/ # Shared, reusable UI components
│ ├── ProductCard.tsx # Card with spring press animation + heart toggle
│ ├── SkeletonCard.tsx # Pulse shimmer skeleton (Reanimated)
│ ├── HeartButton.tsx # Bounce/pop animation on favourite toggle
│ ├── ImageGallery.tsx # Paged horizontal scroll with dot indicators + RTL mirror
│ ├── ErrorState.tsx # Error UI with retry button
│ ├── EmptyState.tsx # Empty favourites state
│ └── ErrorBoundary.tsx # Class component error boundary with recovery UI
├── constants/
│ └── layout.ts # Direction + Language enums
├── features/
│ ├── products/
│ │ ├── screens/ # ProductListScreen, ProductDetailScreen
│ │ └── hooks/ # useProducts (infinite scroll), useProductDetail
│ └── favorites/
│ └── screens/ # FavoritesScreen
├── hooks/ # Cross-feature hooks
│ ├── useDebounce.ts # Generic debounce (300ms for search)
│ ├── useFavorites.ts # Favourite toggle + haptic feedback
│ ├── useLanguage.ts # Language toggle + RTL flag
│ └── useDarkMode.ts # Dark mode toggle + colour palette
├── navigation/
│ ├── RootNavigator.tsx # NavigationContainer + deep link config
│ ├── TabNavigator.tsx # Bottom tabs + Products stack navigator
│ └── types.ts # Typed navigation params (RN TypeScript guide)
├── store/
│ ├── favoritesStore.ts # Zustand + MMKV — persisted favourites list
│ ├── settingsStore.ts # Zustand + MMKV — language + dark mode
│ └── mmkvStorage.ts # Shared MMKV storage adapter factory
├── i18n/
│ ├── index.ts # i18next initialisation
│ └── translations/
│ ├── en.ts
│ └── ar.ts
└── theme/
├── colors.ts # Light/dark colour palettes
└── spacing.ts # Spacing, border radius, font size tokens
Why feature-based?
Each feature (products, favorites) owns its screens and domain hooks. Shared concerns (components, global hooks, theme) live at the src/ level. Adding a new feature is a self-contained folder — no cross-feature coupling required.
Choice: Infinite Scroll
- Mobile users expect continuous scrolling, not paginated "Next" buttons.
useInfiniteQueryfrom TanStack Query provides clean automatic page tracking viagetNextPageParam.- DummyJSON's
limit/skipAPI makes cursor-based loading trivial. - Trade-off documented: memory accumulates with many pages. Acceptable here since users typically find products within the first 2–3 pages. A production app would add a virtualised window.
Zustand + MMKV (Bonus item — replaces useState)
- Zustand is chosen over Redux Toolkit for minimal boilerplate and no mandatory Provider wrapping. Selector subscriptions prevent unnecessary re-renders.
- MMKV replaces AsyncStorage. It is synchronous, C++ native, and ~10× faster on benchmarks — no async hydration flicker on startup.
- Zustand's
persistmiddleware handles JSON serialisation; MMKV provides the synchronous storage adapter.
Two stores:
favoritesStore— persistedProduct[]array.settingsStore— persistedlanguage('en' | 'ar') andisDarkMode.
react-i18next is used (i18next recommended in the spec). Justified: it provides React hooks (useTranslation), lazy namespace loading, and is the most widely adopted i18n solution in the React ecosystem.
RTL without I18nManager.forceRTL: System-level RTL via I18nManager requires an app restart and prevents animated transitions. Instead, isRTL = language === Language.AR drives conditional flexDirection, textAlign, and direction props throughout the app. This enables the live LinearTransition.springify() layout animation when switching languages. Language is persisted via MMKV and re-applied to i18n on app launch.
Prices, ratings, and stock counts are wrapped in explicit flexDirection: 'row' containers to remain LTR even within RTL layouts.
| Requirement | Implementation |
|---|---|
| Card press scale 0.96 | useSharedValue + withSpring on pressIn/pressOut |
| Skeleton shimmer/pulse | withRepeat(withSequence(withTiming)) opacity loop |
| Heart bounce/pop | withSequence(withSpring(1.2), withSpring(1)) |
| Language layout transition | LinearTransition.springify() on Animated.View |
| Tab icon scale on active | withSpring(1.2) driven by focused prop |
| Screen transition | isRTL ? 'slide_from_left' : 'slide_from_right' in NativeStack options |
No Animated API from React Native core is used anywhere.
- Zustand state management (replaces useState)
- TanStack Query v5 for data fetching (replaces useEffect)
- Dark mode toggle persisted to MMKV
- Unit tests —
useDebounce(5 cases) +useFavorites(6 cases) via Jest + React Native Testing Library - Accessibility —
accessibilityRole,accessibilityLabel,accessibilityState,accessibilityHinton all interactive elements - Haptic feedback on favourite toggle (
react-native-haptic-feedback, vibration fallback enabled)
- RTL header: The native NativeStack header doesn't auto-flip without
I18nManager.forceRTL. Solved by hiding the native back button (headerBackVisible: false) and rendering a custom chevron (‹/›) that swaps betweenheaderLeftandheaderRightbased onisRTL. The dark mode toggle in the Favourites header also swaps sides. Slide animation direction isisRTL ? 'slide_from_left' : 'slide_from_right'. - Search + category simultaneously: DummyJSON doesn't support combined filters. Search takes priority when both are active; the UI clears search when a category is tapped.
- Replace the unicode chevron (
‹/›) back button with an SVG icon that matches the native back button pixel-perfectly. - Replace the built-in
Imagecomponent withreact-native-fast-imagefor better disk/memory caching, priority queuing, and progressive loading on product images. - E2E tests with Detox covering the product → detail → favourite → favourites tab flow.
- Shared element transition from product card thumbnail to detail image gallery.
- Virtualised/windowed list for infinite scroll to cap memory usage.