Skip to content

Networking Conventions

Status: Active Last reviewed: 2026-06-30

App-specific protocol layer for how TwoMore keeps data fresh — the four freshness tiers (LIVE / NOTIFY / AMBIENT / ON-DEMAND), the twelve fan-out and UX protocols (A–L), and the three canonical composer hooks. This doc covers the decision rules; docs/best-practices/networking-and-caching.md is the generic deep-dive on TanStack Query patterns, cache shapes, and adapter wiring. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (ARCH-6, DATA-9..DATA-12).

Freshness tiers — when to use realtime vs push vs focus-refetch vs cache (FOUNDATION)

Every data surface belongs to exactly ONE freshness tier, and the tier dictates the mechanism. This is the foundation the Network Protocols below operate within: protocols A-L shape HOW a tier's fetches are bounded; the tier decides WHICH mechanism keeps the data fresh. Origin: a 2026-05-22 deep-dive found realtime (Supabase Postgres Changes) over-applied to low-urgency data — dues, attendance, manner-tags, posts, payments — each costing an RLS check per change per subscriber (Supabase docs: "100 subscribers + 1 insert = 100 reads"), processed single-threaded. Realtime is the most expensive freshness tool and must be rationed.

The decision rule — realtime (a persistent Postgres-Changes channel) is justified ONLY when all three hold:

  1. Actively watched — the user dwells on a screen watching THIS data change (not glance-and-leave).
  2. Latency-critical — seconds of staleness degrade the experience or correctness.
  3. Multi-actor — the change comes from someone else (if you're the only writer, optimistic + local invalidation is enough).

If any condition fails, use the cheaper correct tool. The four tiers:

  • LIVE — focus-gated poll. Actively-watched, latency-critical, multi-actor. No persistent Postgres-Changes channels — the scorecard / match-board / live banner use a focus-gated 10s manual-invalidate poll inside useLiveSessionData (pauses when screen is blurred or session reaches a terminal state: completed/cancelled; resumes on focus via Protocol L). Manual setInterval + invalidateQueries (not per-query refetchInterval) is used because these 6 queries are shared with other screens — a per-query interval would poll them while the scorecard is NOT focused. 60s staleTime is the background fallback. Surfaces: live match scores + score-calls + session status + session RSVP, all via useLiveSessionData. Per-subscriber RLS cost is avoided entirely (no socket channels open).
  • NOTIFY — FCM push + cache-bust. "Something happened, tell me." Mobile OS closes WebSockets on background, so a notification's delivery mechanism is a push notification, not a socket. The in-app badge stays fresh via: the push-received/tapped handler invalidating the keys (use-notification-handler.ts busts signalKeys.active + unreadCount) + the app-foreground catch-up (signals carry 60s staleTime → refetched on resume) + refetchOnMount. Surface: notification bell (signals). No persistent realtime channel.
  • AMBIENT — scoped refetch-on-focus (push + staleTime). Glance-and-leave shared state on a frequently-revisited screen. Freshness is carried by: (1) staleTime: STALE_TIME.realtime (60s) on the screen's primary queries — they self-invalidate when an observer is active; (2) refetchOnWindowFocus + refetchOnReconnect (global TanStack defaults — ON) — returning from background or network reconnect refetches any stale observer; (3) push-triggered invalidation (use-notification-handler.ts busts the relevant entity keys on tap/foreground push); (4) foreground catch-up (useAppForegroundRefresh, Protocol D isStale() predicate — renamed from useRealtimeAppState 2026-06-01) — covers any stale observer on app-resume; (5) mutation-side optimistic patches + onSettled invalidation for own writes. No realtime channels — the home AMBIENT channels were removed 2026-06-01 after measuring per-subscriber RLS cost on high-frequency tables. Surface: home session/match/RSVP state — push+staleTime+refetchOnFocus. NB if you use a scoped focus-refetch: it MUST be scoped to the screen's keys AND isStale()-gated — an unscoped/ungated one reintroduces the foreground burst (Protocol D).
  • ON-DEMAND — staleTime + optimistic + invalidate-on-mutation. Not actively watched on its screen (you read it, you don't watch it change); the viewer is usually the mutator. Your own writes feel instant via optimistic patch + onSettled invalidation of the relevant keys (INCLUDING the byUser/list keys other actors read); cross-actor changes surface on next focus/mount/reconnect within staleTime. Surfaces: dues, attendance, manner-tags, session-payments, board posts/comments/reactions. No realtime channel — even a focus-gated one is wasted when nobody's watching the data change.

Realtime anti-patterns removed to date: (1) ALWAYS-ON channels not gated on focus (2026-05-22) — signals, session_payments were mounted at the app shell; moved to NOTIFY + ON-DEMAND respectively. (2) Realtime for unwatched data (2026-05-22) — board posts, dues, attendance, manner-tags moved to ON-DEMAND. (3) Home AMBIENT channels + ALL remaining Postgres-Changes channels (2026-06-01, owner decision) — all 8 no-op stub files deleted; live screens switched to focus-gated 10s polling (useLiveSessionData, stops when terminal); use-dm-realtime deleted — DM uses native refetchInterval (thread screen 5s, inbox 15s) instead. No Postgres-Changes subscriptions remain; hooks/realtime/ dir deleted.

Security / pipeline invariants:

  • No persistent Postgres-Changes channels exist in the app (all retired 2026-06-01). If a future channel is added, reintroduce the auth/socket lifecycle deliberately and keep it RLS-gated; do not copy the pre-retirement auth/registry wiring pattern from old docs (see git history before 2026-06-01 for the retired shape).
  • Every refresh path invalidates ONLY the current user's keys — never a cross-user prefix.
  • No PII in realtime payloads (carry userId, resolve names at read time — the no-pii-keys-in-jsonb-writes rule covers the write side).
  • realtime.subscribe is allowed ONLY inside presentation/hooks/realtime/** (enforced by no-realtime-subscribe-outside-realtime-hooks) — and every such hook MUST be focus-gated (useFocusedEffect). hooks/realtime/ dir is absent (2026-06-01: use-dm-realtime deleted); the LIVE-tier scorecard/match-board uses focus-gated 10s manual-invalidate polling (useLiveSessionData, stops when terminal); DM uses native refetchInterval. The realtime-focus-gate arch-test asserts the dir stays empty and validates any future hook.

When adding a data surface: classify its tier FIRST (apply the 3-condition rule), then wire the tier's mechanism. Reaching for realtime is the exception that must clear all three conditions, not the default.

Network protocols (canonical fan-out rules)

These six protocols compose to keep network traffic bounded and predictable. Apply them whenever you touch a query hook, a list-rendered component, or a realtime subscription. Origin: the 2026-05-14 network audit found a 40-match scorecard firing ~120 parallel HTTP requests because per-item hooks lived inside list-rendered children.

  • Protocol A — Bulk-fetch hook always exists. Every per-item query hook MUST have a bulk sibling at parent scope. Naming convention: useXxx(parentId, itemId)useXxxByList(parentId): Map<itemId, Xxx> (or array — caller memoizes into a Map). If the per-item hook has no bulk sibling, that's the bug — add the bulk hook (port method + adapter + presentation hook) in the same commit before touching consumers. Examples already in tree: useMyRsvp(userId, sessionId)useMyRsvps(userId). A bulk sibling that spans MULTIPLE parents needs the reduction at BOTH layers, not just the client fan-out. useMyAdminClubAttention (2026-09-08 perf pass, owner-reported on-device lag) fanned out useClubMembers(id) × N clubs AND useClubAdminSnapshot(id) × M admin clubs via useQueries — replacing the client-side useQueries loop with ONE .in('club_id', ids) read fixed the membership half, but the M admin-snapshot RPCs stayed N round trips until a NEW server-side bulk RPC (get_my_admin_club_snapshots, migration 00634) resolved the caller's admin clubs itself and looped the EXISTING per-club get_club_admin_snapshot(uuid) internally (one DB call, zero duplicated aggregation logic — never re-derive the per-club SQL, delegate to it). Two lessons: (1) a bulk client read still costs N round trips if the underlying RPC is still called per id in a loop — check whether the RPC itself needs a caller-scoped multi-row form; (2) a queryFn for a bulk-by-map hook must return Array<[id, Xxx]> entries, never a Map directly — a Map rehydrates as a plain object from the persisted cache (@twomore/json-safe-queryfn; derive the Map view in a useMemo), and any cache-priming backfill into the per-item keys (e.g. clubKeys.members(id), clubAdminKeys.snapshot(id)) must go through a writeXBackfill helper in presentation/cache/, never a raw setQueryData (@twomore/no-setquerydata-outside-cache-helpers, AGENTS.md DATA-8).

  • Protocol A′ — Header/badge content is not first-paint content; gate its fan-out. A rollup that renders in a persistent header (a bell badge, an always-visible chip) mounts on EVERY screen the header appears on, including a screen's very first paint — its queries compete with that screen's own critical-path queries for the same connection pool the instant the app opens, even though the rollup itself is glance-later content. Defer the ENTIRE fan-out (not just soften its priority) behind InteractionManager.runAfterInteractions — flip a ready boolean once initial interactions/paint settle, and gate every query in the fan-out on it (enabled: ready && …). Critically, a loading/pending flag derived from these queries must ALSO read "pending" while !ready, not just while a query is in flight — isLoading: hasWork && (!ready || query.isLoading) — otherwise a consumer reads a false "done, nothing here" the instant the hook mounts, before the deferred fan-out has even started. useMyAdminClubAttention is the canonical example (feeds NotificationBell, mounted on every screen's header).

  • Protocol B — Prop-or-fetch convention for list-rendered components. Any component that may render inside a .map() MUST expose its per-item dependencies as optional props. The internal hook becomes a FALLBACK gated on prop === undefined. Three guarantees: (1) incremental migration — existing call sites keep working; (2) standalone rendering still works (detail/preview); (3) bulk-pass is the explicit signal "this row is part of a list."

    tsx
    function ExampleCard({ session, userId, myRsvp: myRsvpProp }) {
      const fallback = useMyRsvp(userId ?? '', session.id, {
        enabled: myRsvpProp === undefined && Boolean(userId),
      });
      const myRsvp = myRsvpProp !== undefined ? myRsvpProp : fallback.data;
      // ...
    }

    Components named *Card / *Row / *Item are presumed list-rendered. New per-item hooks added inside them without a corresponding prop are a bug. (Note: the canonical list card, SessionCardV2, goes one step further than this fallback pattern — it is a PURE zero-data-hooks projection with no internal fallback hook at all; Protocol A's bulk sibling — useSessionCardModels — is mandatory at the parent, per Conventions › Session Surfaces.)

  • Protocol C — Event precision. For any future realtime/push/manual-poll event handler: (1) scope the upstream fetch or event as narrowly as the source supports; (2) client-filter via a known id Set when the event doesn't carry the parent's id directly; (3) invalidate ONLY the keys the event actually changed — never the parent *.all prefix.

  • Protocol D — Foreground catch-up respects staleTime via an isStale() predicate. The foreground/resume catch-up MUST gate on staleness, NOT fire a blanket invalidation. Correct form (see packages/app/src/presentation/hooks/use-app-foreground-refresh.ts): queryClient.invalidateQueries({ type: 'active', predicate: (q) => q.isStale() }). Common misconception (do not repeat): bare invalidateQueries({ type: 'active' }) does NOT "only refetch stale queries" — it force-refetches EVERY active observer immediately regardless of freshness (it marks them stale AND triggers an active refetch). On a navigation-heavy app where screens stay mounted (detachInactiveScreens keeps the React tree), the active-observer set grows, so a blanket catch-up fires a progressively larger burst that saturates the concurrency limiter into multi-second tails. The predicate: (q) => q.isStale() restores the intended semantics — only observers already past their own staleTime refetch. (refetchQueries is even worse — it refetches regardless of isInvalidated too.)

  • Protocol E — Selector over second-fetch. When children need a derived view of parent data (count, filter, mapping), use useMemo over the parent's already-fetched data OR TanStack's select option on the same useQuery key — never a separate query that re-fetches a slice of what's already cached. Pure-CPU derivations are not I/O; routing them through TanStack just churns cache keys.

  • Protocol F — Detail reads list cache as placeholderData. When the user navigates from a list to a detail screen, the detail's first-fetch should not refetch if the list cache already has the row. Pattern: placeholderData: () => listCache.find(i => i.id === detailId). Combine with TanStack's stale-while-revalidate so the placeholder paints instantly while the detail-specific fetch revalidates in background.

Foreground UX protocols (perceived-latency rules)

Network Protocols A-F shape what data is fetched and when. These four UX Protocols shape how the user perceives the loading process. They are orthogonal — a screen can have perfect network shape and still feel laggy if it ignores G/H/I, and a screen can hide good network shape behind sloppy UX. Keep the layers separate: never solve a network problem by changing the UI loading pattern, and never solve a UX problem by re-shaping queries.

  • Protocol G — Screen-level readiness gating. A screen's content area renders all sections together, not section-by-section. Cause of the anti-pattern: each section fires its own useQuery, queries resolve at different times, and sections that resolve to "empty" still flash a skeleton-then-null reveal. Fix: combine the critical-path queries' isFetched flags at the screen root and render a unified skeleton until ALL settle. Sub-sections (via DataSection) still own their final empty/content state — Protocol G just removes the cascading reveal. Use isFetched not isLoading so empty results count as ready. Critical path = the queries the screen's primary surface visually depends on; secondary background queries (achievements counts, friend lists) can resolve independently. Apply to surfaces where the user reads the whole screen as one unit (profile, records, club-detail home tab).

  • Protocol H — Anticipatory prefetch on onPressIn. Any pressable that navigates to a detail screen MUST call the destination's prefetch helper on onPressIn (press intent, ~100ms before navigation actually starts). The prefetch fires the destination's primary queries in parallel; by the time the route transition animation completes, the cache is warm. Wiring template:

    tsx
    <Pressable
      variant="card"
      onPressIn={() => prefetchSpectatorScorecard(queryClient, session.id)}
      onPress={() => appRouter.push(routes.scorecard(session.id) as never)}
    >

    For each detail screen, add a prefetchXxxScreen(client, id) helper to packages/app/src/presentation/cache/prefetch.ts that fires every primary query the screen mounts. Combine with Protocol F (placeholderData from list cache) so a destination renders instantly with cached data even before prefetch resolves.

  • Protocol I — Foreground vs background separation. Network Protocols (A-F) are background concerns — what to fetch, how to dedup, when to invalidate. UX Protocols (G-J) are foreground concerns — what the user sees during loading and transitions. Do not conflate them. A failing UX (laggy tap, piecewise reveals) is often misdiagnosed as a network shape problem. Before refactoring a query, ask: is this a fetch shape failure, or a perception failure? If the data is already cached but the screen still feels slow, the fix lives in G/H/J, not A/B/C.

  • Protocol J — Information architecture: screens have one job. Each top-level tab has a primary mental model the user assigns to it. 홈 = today's activity. 클럽 = my clubs + discovery. 경기 = matches I'm playing/RSVP'd. 기록 = analytics + leaderboards + personal records. 프로필 = identity + social + settings. Sections drift across screens over time; periodically audit "is this card on the right screen?" Analytics content (PersonalRecordsSection — biggest win, current streak, busiest week, best ELO swing) belongs on 기록, NOT 프로필. The check: if removing the section makes the host screen MORE focused on its primary job, the section should move.

  • Protocol K — Mapper resilience. A single Zod schema failure inside .map(toDomain) must not crash the whole query. Two complementary patterns: (1) boundary-level safeMap(rows, mapper, context) from packages/app/src/adapters/supabase/base.adapter.ts wraps every adapter list-query .map() site and drops individual rows that throw, logging logger.warn + a Sentry breadcrumb keyed to the entity context (e.g. 'adapter', 'safeMap:club.list dropped row') so the breadcrumb is searchable in the Sentry dashboard; (2) per-field safeParse(...).data ?? fallback for nullable enums and required-with-default fields in mapper files, leaving .parse() only for required-no-default fields where the row is fundamentally broken without that field (safeMap then drops the row). Single-row findById / .maybeSingle() paths are deliberately NOT wrapped — failure there should propagate so screens render an error state instead of pretending the row didn't exist. Add new bulk-query adapter methods using safeMap(rows, xMapper.toDomain, '<entity>.<scope>') so the context label is consistent and searchable. Enforced by @twomore/require-safemap-in-adapter-list (Wave H) — flags any bare .map(row => x.toDomain(row)) in packages/app/src/adapters/; the rule surfaced 6 residual unwrapped sites that a manual grep sweep had missed, validating mechanical enforcement over hand-audit.

  • Realtime is not a default dependency. The app must work fully without persistent sockets. Current data freshness is carried by TanStack staleTime/refetch-on-focus, push-triggered cache busts, focused polling for live surfaces, native DM refetchInterval, optimistic writes, and targeted invalidation. A future socket-based hook needs an explicit freshness-tier justification and must remain an enhancement over the query source of truth.

  • Protocol L — Pause-on-blur for active subscriptions. The bottom tabs use detachInactiveScreens: true, which removes inactive screens from the native view tree but keeps the React component tree mounted. Every setInterval / realtime subscription on a visited screen therefore stays live in the background, accumulating callback cost as the user navigates around. Every hook that subscribes to an external system MUST gate its subscription using the focus-aware primitive:

    • The primitive: useFocusedEffect(effect, deps) from @twomore/app (or @/presentation/hooks/use-focused-effect within the app package) — runs the effect only while the consuming screen is currently focused, pauses on blur, resumes on focus. Internally wraps useEffect + useIsFocused. Every clock-ticker and realtime hook in the codebase uses this primitive so the focus-gate behavior stays consistent across the app. New hooks that subscribe to external systems MUST use it (enforced by @twomore/require-is-focused-in-realtime-hook).
    • Clock-tickers: built into packages/app/src/presentation/hooks/use-now-ticking.ts, packages/app/src/presentation/hooks/use-live-countdown.ts, packages/app/src/presentation/hooks/use-live-countdown.ts (useDeadlineCountdown), packages/app/src/presentation/hooks/use-shared-ticker.ts — all via useFocusedEffect internally (preferred for multi-consumer cadences — collapses N per-row setIntervals into 1 broadcaster per cadence so React 18 batches all updates into one commit).
    • Realtime hooks: packages/app/src/presentation/hooks/realtime/ is absent after the 2026-06-01 retirement. The realtime-focus-gate arch-test asserts it stays absent, and @twomore/require-is-focused-in-realtime-hook validates any future hook if the directory returns.
    • On focus re-resume: no blanket manual invalidate is needed. TanStack's refetchOnWindowFocus covers app-foreground catch-up; navigation-focus catch-up happens naturally via staleTime on the next observer notification.
  • Protocol M — Tracked-props discipline: background refetches must be render-free and invisible. TanStack v5 re-renders an observer only for the result properties the render actually READ (tracked props). isFetching, isRefetching and fetchStatus flip twice on EVERY refetch (idle → fetching → idle) — focus catch-up, invalidation, and the live screens' 10 s poll included — so a derivation that reads them subscribes the WHOLE screen to every fetch start/stop even when the data comes back byte-identical (structural sharing keeps the reference, so data alone would not re-render at all). The 2026-09-08 lag audit found useCompositeNetwork/deriveNetworkPhase reading fetchStatus on every composed query and 45 screens wiring refreshing={isRefetching}: session detail / scorecard / match board re-rendered ~8× per poll tick AND flashed a pull-to-refresh spinner nobody pulled. Rules: (1) a composed-network derivation reads isPaused (flips only when connectivity pauses a fetch) and touches isFetching only after a paused query is found — QueryLike deliberately has no fetchStatus; (2) the pull-to-refresh spinner is a USER-gesture acknowledgement — const { refreshing, onRefresh } = usePullToRefresh(refetch) from @twomore/app is the one way to drive refreshing/onRefresh (@twomore/no-background-refetch-spinner, error, zero waivers); (3) a composer never exposes isRefetching — expose retry/refetchX and let the screen compose the hook; (4) a per-render fetching cue for an in-place list refresh is allowed only where the product wants the cue (VenueFinder's browse list), never as a side-effect of the spinner wiring. Corollary on the persistence side: the lazy persister skips re-serializing a query whose state.data reference is unchanged (structural sharing) for 5 minutes, so an unchanged poll response costs no JSON.stringify/MMKV write either.

  • Protocol N — One round trip per screen (the composition layer is the bottleneck). React Native Android's OkHttp Dispatcher allows 5 concurrent requests per host and every Supabase DATA call goes to one host, so a screen's mount latency is ceil(requests / 5) × per-request time regardless of how well each hook follows Protocol A. (Verified 2026-09-09 against what actually runs: global fetchwhatwg-fetchXMLHttpRequestNetworkingModuleOkHttpClientProvider, which in RN 0.83.6 never calls .dispatcher(...), so OkHttp 4.9.2's defaults of 5-per-host / 64-total apply. Images do NOT share that client: expo-image builds its own OkHttpClient in ExpoImageOkHttpClientGlideModule, so map tiles and avatars get a SEPARATE 5-per-host budget and never contend with queries — do not count them against this budget.) Composites that are each correct in isolation (useSessionCardModels 8, useLiveSessionData 5, useSessionDetailModel +14, useHomeData 8–10) stacked into 18–22 requests per screen and 2–4 s tails (2026-09-09 loading audit). Rule: a screen's FIRST PAINT reads through ONE aggregate — a SECURITY INVOKER SQL function returning jsonb built from the SAME columns the per-item adapters select (RLS applies unchanged; INVOKER, never DEFINER, when every source table is already caller-scoped), mapped with the existing mappers (Protocol K per part), and BACKFILLING the per-item cache keys through presentation/cache helpers so detail screens stay warm. Shipped: get_session_card_bundle(uuid[]) (00635, every session list), get_home_snapshot() (00636), get_session_detail_bundle(uuid) (00637, every live-session screen) and get_club_detail_bundle(uuid) (00638). A focus-gated poll is a per-tick fan-out too: the live-session poll invalidated 7 shared keys every 10 s, i.e. 7 requests per tick through the same 5-slot dispatcher, on exactly the screens the owner reported as laggiest. With an aggregate the poll invalidates ONE key and the backfill republishes every per-item key the 7 used to cover — so a live tick costs 1 request, and no other screen goes stale. Any new poll or refresh path invalidates the bundle key, never the parts. Add a new aggregate when a screen composes more than ~6 requests at mount; never a per-screen useQueries fan-out. Secondary content (badges, glance-later rollups) still defers behind first paint (Protocol A′). Do not reach for the native knob instead. Raising maxRequestsPerHost needs a custom OkHttpClientFactory set in MainApplication.onCreate, which in this CNG (no committed android/) project means a config plugin patching MainApplication.kt; the factory is read once at first client creation (RN issue #34789), the same once-only shape that boot-crashed the retired feature-flag-override plugin. It is also unnecessary: with the aggregates a screen's first paint is 1–3 data requests, well under the cap. Fix the composition, not the transport.

Network Orchestration — composer hooks

The Network Protocols A-L describe individual rules. The composer hooks codify the COMPOSITION — how the rules apply together for a specific domain. Each composer owns the network shape (queries + realtime + ready-gate) for one logical entity:

  • useLiveSessionData(sessionId) from packages/app/src/presentation/hooks/composites/use-live-session-data.ts — canonical setup for any screen displaying a live session: 4 queries (session + matches + rsvps + profiles), focus-gated 10s manual-invalidate poll (stops when session.status is 'completed'/'cancelled'), Protocol-G ready-gate combining all 4 isFetched flags. Returns { session, matches, rsvps, clubProfiles, ready, refetchSession, refetchMatches } (no isRefetching — Protocol M). As of 2026-09-09 all four slices come from ONE get_session_detail_bundle read (Protocol N) and the 10 s poll invalidates only that bundle key; the return shape is unchanged for its consumers. Used by session-detail-screen, spectator-scorecard-screen, match-board-screen, and live-session-stack on home. TanStack dedupes across consumers via cache keys.
  • useClubContext(clubId) from packages/app/src/presentation/hooks/composites/use-club-context.ts — canonical setup for any club detail surface: one get_club_detail_bundle read (Protocol N, 2026-09-09) with role derived from it via the pure deriveClubRole extracted from useClubRoleuseClubRole itself is unchanged for its other callers, Protocol-G ready-gate over club+members. Returns { club, members, userId, role, ready, isError, retry, queries } where role is the full UseClubRoleReturn (canManage* flags + hasPermission + isMember) and queries is the [clubQ, membersQ] objects to spread into a screen's <QueryBoundary> alongside screen-specific queries. Used by club-detail, club-members, club-dues, club-attendance, club-board. Eliminates the repeated useAuth+useClub+useClubMembers+useClubRole quartet + inline members.some(m => m.userId === uid && m.isActive) isMember derivation.
  • usePlayerProfileData(userId) from packages/app/src/presentation/hooks/composites/use-player-profile-data.ts — canonical setup for the profile/records cluster: useProfile + useMatchHistory + useFriends, Protocol-G ready-gate over all three. Returns { profile, matches, friends, ready, isError, retry, queries }. Used by profile-screen, records-screen, records-history-screen — each adds its screen-specific hooks (achievements / manner-tags / player-stats-detail / my-clubs) on top and spreads queries into its <QueryBoundary>. NOT used by public-profile-screen (it reads only profile + a 5-row preview + a pairwise useFriendship, sharing no friends list — two hooks isn't boilerplate worth a composer).

New screens displaying the same entity MUST use the composer — never re-implement the boilerplate. Drift between screens is the failure mode the composer prevents.

Pattern for new composers:

  1. Identify the entity (live session, user activity, club detail, etc.)
  2. List the canonical queries + polling interval (if LIVE-tier) + ready-gate for it
  3. Wrap them in a useXxxData(id) composer in presentation/hooks/composites/ returning the data slices + ready/isError/retry + the critical-path queries[] (for <QueryBoundary> composition) + any refetch* callbacks
  4. Migrate every screen consuming the entity to the composer
  5. Document the composer in CLAUDE.md's Network Orchestration section

Future best-practice migration (not yet done): TanStack v5's queryOptions factory is the recommended building block for sharing a query's config between a hook, a prefetch helper, and a composer (one source of truth for queryKey + queryFn + staleTime, fully typed). Our query hooks currently inline useQuery({...}); migrating them to export xxxQueryOptions(id) and having hooks/prefetch/composers consume those would remove the last duplication (queryKey ↔ prefetch ↔ hook). Tracked as a follow-up — do it incrementally as query hooks are touched.

See also

Markdown remains the source of truth. Run yarn docs:check before handoff.