Refactored, production-ready architecture. Same liquid-glass UI and animations as v1, with cleaner separation of concerns and significant performance improvements.
cd MetalVerse
npm install
npx expo startOptionally add a Google Places API key in app.json → expo.extra.googlePlacesKey to enable live location search (the Locations tab works with mock data without one).
MetalVerse/
├── App.js # Provider tree
└── src/
├── api/ # Network layer
│ ├── http.js # Configured axios instance + ApiError
│ ├── cache.js # Two-tier (memory + AsyncStorage) cache
│ ├── createCachedService.js # Factory: removes service boilerplate
│ ├── metalsApi.js
│ ├── cryptoApi.js
│ ├── fxApi.js
│ ├── placesApi.js
│ └── index.js
│
├── config/ # Configuration & domain reference data
│ ├── index.js # Single source of all env-derived config
│ └── domain.js # METALS, UNITS, CURRENCIES, etc. + lookup maps
│
├── theme/ # Pure design tokens
│ ├── tokens.js # Colors, Spacing, Radius, Typography, Animation
│ └── index.js
│
├── components/ # UI — split by purpose
│ ├── primitives/ # GlassPanel, GlowButton, FieldLabel
│ ├── inputs/ # DropdownPicker, SegmentedSelector, NumericInput
│ ├── layout/ # CosmicBackground, ScreenHeader
│ └── index.js
│
├── animations/ # Pure visual/motion components
│ ├── MetalOrb.js
│ ├── AnimatedNumber.js # Now updates on UI thread (zero React re-renders)
│ ├── CashVisualization.js
│ └── index.js
│
├── context/
│ └── PricesContext.js # Single shared market-data provider
│
├── hooks/
│ ├── useConvertAnimation.js # Animation choreography
│ ├── useDebouncedValue.js
│ └── index.js
│
├── utils/ # Pure functions, easy to unit test
│ ├── converter.js # Metal → USD → output pipeline
│ ├── format.js
│ ├── validation.js
│ └── haptic.js
│
├── screens/ # One folder per screen, with sub-components
│ ├── convert/
│ │ ├── ConvertScreen.js # Orchestrator (form state + result)
│ │ ├── ConvertHero.js # Orb + value + cash
│ │ ├── ConvertControls.js # All form inputs
│ │ └── SpotPriceCard.js
│ ├── metals/
│ │ ├── MetalsScreen.js
│ │ └── MetalCard.js
│ ├── crypto/
│ │ ├── CryptoScreen.js
│ │ └── CryptoCard.js
│ ├── locations/
│ │ ├── LocationsScreen.js
│ │ ├── LocationSearchForm.js
│ │ └── LocationResultRow.js
│ └── index.js
│
└── navigation/
├── RootNavigator.js
├── GlassTabBar.js
└── index.js
- Before: each service had its own copy of cache + stale + fallback logic.
- After:
createCachedServicefactory wraps a fetcher with the cache/stale/fallback dance. Each service now declares what changes (URL, parsing, fallback) and reuses what doesn't. - Before:
placesApi.jsreached intoexpo-constantsdirectly. - After: all environment values flow through one
config/module. Services only know aboutconfig.
- Before: three screens (
Convert,Metals,Crypto) each calleduseLivePrices(). Three concurrent fetch loops, three timers, three cache states. - After: a single
PricesProviderat the app root. Selector hooks (useMetalPrices,useCryptoPrices,useFxRates) subscribe to one slice. The provider also pauses polling when the app backgrounds and prevents overlapping fetches.
The provider exposes two contexts:
PricesDataContext— the actual pricesPricesMetaContext—loading,lastUpdated,warning,refresh
A component reading only metalPrices doesn't re-render when lastUpdated ticks every minute.
The 358-line ConvertScreen became:
ConvertScreen— form state + delegationConvertHero— orb + animated value + cash visualizationConvertControls— all the input fieldsSpotPriceCard— current metal spot price
Now typing in the amount field doesn't re-render the orb or the spot price card.
Each screen got its own folder with co-located sub-components. Easier to find related code, easier to modify one screen without touching anything else.
The original used setInterval + setState at ~60fps to tween the displayed number. That's roughly 60 React re-renders per conversion, each propagating through the screen tree.
The new implementation uses Reanimated's useAnimatedProps on an AnimatedTextInput. The text content is updated on the UI thread; zero React re-renders during the animation. Same visual result, no main-thread cost.
Picker option arrays (METALS.map(...), etc.) used to be rebuilt inline on every render — new array identity every time, breaking memoization of children. Now they're module-level frozen constants in config/domain.js (METAL_OPTIONS, UNIT_OPTIONS, CURRENCY_OPTIONS, CRYPTO_OPTIONS, OUTPUT_MODE_OPTIONS).
Frequent .find(...) calls (e.g., METALS.find(m => m.id === metalId)) are now O(1) lookups via pre-built maps (METAL_BY_ID, UNIT_BY_ID, CURRENCY_BY_ID, CRYPTO_BY_ID).
Every UI component, animation component, and list item is wrapped in React.memo. With the inline-array fix above, memoization actually does its job now.
- Inline arrow handlers (
onChange={(t) => onChange(...)}) replaced withuseCallback-wrapped handlers inNumericInput,DropdownPickerrows,SegmentedSelectoroptions, and tab bar items. - Module-level constants for static prop values (gradient color arrays, gradient start/end points, button color tuples, etc.) so they don't get fresh identity each render.
- Each tab is a memoized
<Tab />component. When focus changes, only the two tabs whosefocusedprop flipped re-render — not all four. screenOptionsandrenderTabBarare hoisted out ofRootNavigator's render.
The prices provider listens to AppState and:
- Refreshes when the app foregrounds
- Skips polling when overlapping fetches would occur (
inFlightRef) - Stops re-creating its meta object if
loadinghasn't changed
useConvertAnimation owns the random-animation-per-conversion logic. Returns a stable trigger function; child animation components only re-render when the key changes.
The pure-function utilities (utils/converter.js, utils/format.js, utils/validation.js) have zero React or React Native dependencies and can be unit tested directly. Same for the conversion math.
API services depend only on http, cache, and config — easily mockable.
The architecture is set up for:
- Price charts — drop a
<PriceChart />into the Metals/Crypto screens. Historical data fetcher goes intoapi/. Selector hook intocontext/. - Alerts — add
api/alertsApi.js, anAlertsProvider, and a screen. The cache layer already supports persistent storage. - Portfolio tracking — persist holdings via the existing AsyncStorage-backed cache; new
screens/portfolio/folder.
| Aspect | v1 | v2 |
|---|---|---|
| Duplicated fetch loops | 3 (one per screen) | 1 (provider) |
| Re-renders per conversion | ~60 (in number tween) | ~3 |
| Service boilerplate | ~30 lines duplicated 3× | factory, ~5 lines per service |
| Largest screen file | 358 lines | 130 lines (orchestrator) |
| Memoized components | 0 | 20+ |
| Inline option arrays | every render | frozen constants, once |
| Convert screen splits | monolithic | hero / controls / spot card |