Recommendation Tabs System
A multi-tab recommendation surface that stays fast under changing filters and personalization signals.
A tabbed recommendation module (e.g. 'For You', 'Trending', 'Nearby') where each tab has its own data source, pagination, and personalization logic, but shares a single scroll and loading shell.
Problem
Recommendation surfaces are usually the busiest screen in an app — multiple data sources, personalization, pagination, and analytics all competing for the same viewport. A naive implementation re-fetches every tab on every mount, keeps no memory of scroll position, and re-renders the whole list on unrelated state changes (theme, auth refresh, filter chips).
The team needed a tab system where switching tabs felt instant, each tab's data lived independently, and adding a new recommendation source didn't mean touching the tab container at all.
- Switching tabs re-triggered full-screen loading spinners even when data was already cached
- Scroll position and filter state were lost on every tab switch
- Each new recommendation type required changes across 4+ shared files
Architecture
The system is split into a dumb TabShell (handles tab bar, active-index state, and gesture-driven swiping) and independent TabContent modules, each owning its own query key, cache, and view model. Tabs register themselves through a config array rather than being hard-coded, so a new recommendation source is a new config entry plus a self-contained content component.
Each tab's data layer follows the same three-part contract: a fetcher (pure function), a React Query hook (caching + pagination), and a presentational list component. This kept the container agnostic to what any individual tab actually renders.
- TabShell only knows about tab metadata (label, icon, id) — never about data
- Every tab keeps its own React Query cache keyed by ['recommendations', tabId, filters]
- Inactive tabs stay mounted but visually hidden (translateX) instead of unmounting, preserving scroll position
Data flow
On first mount, only the active tab's query fires. Adjacent tabs (prev/next in the swipe order) are prefetched at low priority once the active tab's data resolves, so swiping feels instant without wasting bandwidth on tabs the user may never open.
Filter changes (e.g. category, price range) are lifted to a shared context but each tab's query key includes the filter hash — so filter changes invalidate only the active tab's cache, not all tabs at once.
- Active tab fetches eagerly; adjacent tabs prefetch in the background after a 300ms idle window
- Filter state is shared, but cache invalidation is scoped per tab via query key hashing
- Pull-to-refresh only invalidates the active tab, not the whole tab set
Performance decisions
Keeping inactive tabs mounted trades memory for perceived speed — validated as the right tradeoff since tab count is capped at 5 and each list is windowed. Lists use FlashList-style windowing so off-screen items are recycled regardless of which tab is active.
Tab switch animation runs on the UI thread via Reanimated shared values, decoupled from JS thread data fetching, so a slow network response never blocks the swipe gesture from completing.
- Windowed rendering caps active DOM/view nodes to ~15 per tab regardless of dataset size
- Tab transitions run on the native/UI thread — no frame drops even during data fetches
- Memoized selectors prevent re-renders when unrelated global state (theme, auth) changes
Results
Tab switches went from a 400–600ms full reload to sub-16ms perceived transitions for previously visited tabs. Adding a new recommendation tab dropped from touching ~4 shared files to a single new config entry and content component.
- Perceived tab-switch latency: ~500ms → <16ms for cached tabs
- New recommendation source onboarding: ~4 files touched → 1 new module
- Scroll position and filters persist across tab switches