Networking, Caching & Data Layer Best Practices
Status: Reference
React Native (Expo SDK 55) + Supabase + TanStack Query v5 Researched 2026-03-24. Updated 2026-04-05.
Priority Actions
| Priority | Action | Where |
|---|---|---|
| P0 | Wire onlineManager to expo-network | app/_layout.tsx |
| P0 | Wire focusManager to AppState | app/_layout.tsx |
| P0 | Set refetchOnWindowFocus: false globally | QueryClient config |
| P1 | Thread AbortSignal through Supabase adapters | src/adapters/supabase/ |
| P1 | Add typed AppError hierarchy | src/domain/errors/ |
| P1 | Implement optimistic RSVP mutation | src/presentation/hooks/ |
| Realtime RETIRED → focus-gated polling (§1.10) | presentation/hooks/composites/ | |
| P1 | Add useRefreshOnFocus hook | src/presentation/hooks/ |
| P2 | MMKV persistence for TanStack Query | app/_layout.tsx |
| P2 | Prefetch on list item render | ClubCard, SessionCard |
| P2 | Add lists()/details() intermediate keys | query-keys.ts |
| P2 | Cursor-based infinite queries for history | Session/match history hooks |
| P3 | Mutation persistence for offline | PersistQueryClientProvider |
1. Networking
1.1 HTTP Client — Use Native fetch
RULE: Use native fetch. Do NOT add axios. TanStack Query handles retries, caching, deduplication. Supabase-js uses fetch internally.
1.2 Request Deduplication
RULE: Let TanStack Query handle it via query keys. If 5 components call useQuery with the same key, ONE request fires.
Pitfall: Deduplication breaks if keys differ slightly. Always use factory functions from query-keys.ts. Never construct keys inline.
1.3 Retry Strategies
RULE: Only retry transient errors (5xx, network). Never retry 4xx.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
return false;
}
return failureCount < 3;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30_000),
},
},
});1.4 Request Cancellation
RULE: Pass TanStack Query's AbortSignal to every queryFn.
// BAD
queryFn: () => supabase.from('clubs').select('*').eq('id', id),
// GOOD
queryFn: ({ signal }) =>
supabase.from('clubs').select('*').eq('id', id).abortSignal(signal).single(),1.5 API Error Handling
RULE: Normalize all errors into typed AppError hierarchy. Show Korean i18n messages, never raw error strings.
class AppError extends Error {
constructor(
public code: string,
public userMessageKey: string
) {
super(code);
}
}
class NotFoundError extends AppError {
constructor(entity: string) {
super('NOT_FOUND', `errors.${entity}NotFound`);
}
}
function mapSupabaseError(error: PostgrestError): AppError {
if (error.code === 'PGRST116') return new NotFoundError('club');
if (error.code === '42501') return new PermissionError();
return new AppError('UNKNOWN', 'errors.unknown');
}useQueryError hook: Always show t().ui.errorOccurred to the user via toast. Never expose raw error.message — those are English technical strings unsuitable for Korean users. Log the raw error with logger.error for debugging.
// BAD — leaks English error to user
showToast({ message: error.message });
// GOOD — localized user message, raw error in logs
logger.error('Query failed', { error });
showToast({ message: t().ui.errorOccurred });1.6 Auth Headers & Token Refresh
RULE: Let Supabase-js handle token refresh. Call startAutoRefresh(). Wire AppState.
supabase.auth.startAutoRefresh();
AppState.addEventListener('change', (state) => {
if (state === 'active') supabase.auth.startAutoRefresh();
else supabase.auth.stopAutoRefresh();
});
supabase.auth.onAuthStateChange((event) => {
if (event === 'SIGNED_OUT') queryClient.clear();
});1.7 Network State Detection (MISSING)
RULE: Wire expo-network to TanStack Query's onlineManager at app startup.
import * as Network from 'expo-network';
import { onlineManager } from '@tanstack/react-query';
export function setupNetworkListener() {
onlineManager.setEventListener((setOnline) => {
let initialized = false;
const sub = Network.addNetworkStateListener((state) => {
initialized = true;
setOnline(!!state.isConnected);
});
Network.getNetworkStateAsync()
.then((state) => {
if (!initialized) setOnline(!!state.isConnected);
})
.catch(() => {});
return sub.remove;
});
}ACTION: Call setupNetworkListener() once in app/_layout.tsx.
1.8 Supabase Filter Safety
RULE: Never use string interpolation inside Supabase PostgREST filter arguments (.eq(), .not(), .or(), etc.). Interpolated values can break the filter syntax or introduce SQL injection vectors.
// BAD — string interpolation in .not() filter (SQL injection risk)
const exclude = ids.join(',');
query.not('id', 'in', `(${exclude})`);
// GOOD — safe two-query pattern: fetch IDs first, then filter in application
const { data: excludeIds } = await supabase.from('alerts').select('id').eq('club_id', clubId);
const safeIds = new Set(excludeIds?.map((r) => r.id));
const filtered = results.filter((r) => !safeIds.has(r.id));Background: This was discovered in the club-alert adapter where raw string interpolation in a .not() filter allowed malformed input to alter query semantics. The fix replaced the single interpolated query with a safe two-query pattern.
1.9 Request Batching
RULE: Use Supabase's embedded selects to combine joins. Use Promise.all for independent parallel fetches.
// BAD — N+1
const members = await supabase.from('club_members').select('user_id').eq('club_id', id);
const profiles = await Promise.all(
members.data!.map((m) => supabase.from('profiles').select('*').eq('id', m.user_id).single())
);
// GOOD — single request with join
const { data } = await supabase
.from('club_members')
.select('role, joined_at, profiles(id, display_name, avatar_url, elo_rating)')
.eq('club_id', id);1.10 Supabase Realtime — RETIRED (focus-gated polling instead)
RETIRED 2026-06-01. The app no longer opens persistent Supabase Postgres-Changes channels. Each change costs an RLS check per subscriber, processed single-threaded (Supabase: "100 subscribers + 1 insert = 100 reads"), which made always-on channels too expensive for the freshness they bought.
Current mechanism — every data surface belongs to one freshness tier; the full framework (LIVE / NOTIFY / AMBIENT / ON-DEMAND) is the canon at Conventions › Networking:
- LIVE (scorecard, match-board, live banner) — focus-gated 10s manual-invalidate poll inside
useLiveSessionData(pauses on blur and on terminal session status). No socket channels. - NOTIFY (notification bell) — FCM push + cache-bust. No channel.
- AMBIENT (home session/match/RSVP) —
staleTime+ refetch-on-focus + push-triggered invalidation. No channel. - ON-DEMAND (dues, attendance, board posts, payments) —
staleTime+ optimistic patch + invalidate-on-mutation. DM uses nativerefetchInterval(thread 5s, inbox 15s).
If realtime is ever reintroduced: it must live under presentation/hooks/realtime/**, be focus-gated via useFocusedEffect, and invalidate only the specific keys an event changed — never a *.all root prefix. Do not copy the pre-retirement channel/auth wiring; see git history before 2026-06-01 for the retired shape.
2. Caching
2.1 staleTime vs gcTime
RULE: Set staleTime by data volatility. Set gcTime to 2x-5x staleTime.
useScreenData default: useScreenData now defaults staleTime to STALE_TIME.standard (5 min). Previously it had no default, inheriting TanStack Query's default of 0 (immediately stale), causing unnecessary refetches on every render. Callers can still override with any STALE_TIME.* value when the data volatility differs from standard.
| Data | staleTime | gcTime |
|---|---|---|
| RSVPs, live scores | 30s (focus-poll) | 10 min |
| Player stats | 2 min (frequent) | 15 min |
| Sessions, members | 5 min (standard) | 30 min |
| Profiles, clubs | 15 min (stable) | 60 min |
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_TIME.standard,
gcTime: 1000 * 60 * 30,
refetchOnWindowFocus: false, // Not useful on mobile
refetchOnReconnect: true,
},
},
});2.2 Cache Invalidation
RULE: Use hierarchical invalidation via query key prefixes. Prefer invalidateQueries over resetQueries.
// Hierarchical keys enable scoped invalidation
queryClient.invalidateQueries({ queryKey: clubKeys.all }); // everything
queryClient.invalidateQueries({ queryKey: clubKeys.lists() }); // all lists
queryClient.invalidateQueries({ queryKey: clubKeys.detail(id) }); // one detail2.3 Persistent Cache — MMKV
RULE: Use MMKV for TanStack Query persistence (~30x faster than AsyncStorage). Only persist stable queries.
import { MMKV } from 'react-native-mmkv';
const mmkv = new MMKV({ id: 'tanstack-query-cache' });
const mmkvPersister = createSyncStoragePersister({
storage: {
getItem: (key) => mmkv.getString(key) ?? null,
setItem: (key, value) => mmkv.set(key, value),
removeItem: (key) => mmkv.delete(key),
},
throttleTime: 2000,
});
// Only persist stable queries
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === 'success' &&
(query.options.staleTime ?? 0) >= STALE_TIME.standard,
},2.4 Image Caching
RULE: Use expo-image with cachePolicy="disk" (default). For mutable images (avatars), append ?v={updatedAt} for cache busting.
2.5 Offline-First
RULE: Use networkMode: 'offlineFirst' for reads. Queue mutations with PersistQueryClientProvider. Phase 2 feature.
2.6 Prefetching — Cache Warming
RULE: Prefetch detail data on list item render (not press). Zero loading screen on navigation.
function ClubCard({ club }: { club: Club }) {
const queryClient = useQueryClient();
return (
<Pressable
onLayout={() => {
queryClient.prefetchQuery({
queryKey: clubKeys.detail(club.id),
queryFn: () => registry.clubRepo.findById(club.id),
staleTime: STALE_TIME.standard,
});
}}
onPress={() => router.push(routes.clubDetail(club.id))}
/>
);
}2.7 Cache Size Management
Set gcTime (30 min) for memory. maxAge (24h) on persistence.
RULE: Always chain .limit(N) on every Supabase list query. Never leave a list query unbounded — even "small" tables grow over time, and unbounded SELECTs cause memory spikes and slow responses on mobile.
| Cardinality | Limit | Examples |
|---|---|---|
| High (events, matches, transactions) | 200 | match history, session history |
| Medium (members, alerts, dues) | 100 | club members, notifications |
| Low (clubs, leaderboards) | 50 | club list, rankings |
// BAD — unbounded list query
const { data } = await supabase.from('sessions').select('*').eq('club_id', clubId);
// GOOD — always capped
const { data } = await supabase
.from('sessions')
.select('*')
.eq('club_id', clubId)
.order('date', { ascending: false })
.limit(200);Background: An audit found 10 adapters with unbounded list queries. All have been capped (50–200 depending on cardinality). New adapters must follow this pattern.
3. Data Layer Architecture
3.1 Query Key Design
RULE: Factory pattern with hierarchical keys. Add intermediate lists() and details() levels.
export const clubKeys = {
all: ['clubs'] as const,
lists: () => [...clubKeys.all, 'list'] as const,
list: (filters: ClubFilters) => [...clubKeys.lists(), filters] as const,
details: () => [...clubKeys.all, 'detail'] as const,
detail: (id: string) => [...clubKeys.details(), id] as const,
members: (clubId: string) => [...clubKeys.all, 'members', clubId] as const,
};3.2 Dependent Queries
RULE: Use enabled flag. Never chain with useEffect.
// BAD — waterfall via useEffect
useEffect(() => { if (club) fetchSessions(club.id); }, [club]);
// GOOD — parallel when independent
const clubQuery = useQuery({ queryKey: clubKeys.detail(clubId), ... });
const sessionsQuery = useQuery({ queryKey: sessionKeys.byClub(clubId), ... });
// Only truly dependent
const eloQuery = useQuery({
queryKey: clubEloKeys.byClub(clubId),
queryFn: () => registry.eloRepo.getLeaderboard(clubId),
enabled: !!clubQuery.data,
});3.3 Pagination
RULE: useInfiniteQuery with cursor-based pagination for long lists. Simple useQuery for short bounded lists.
3.4 Optimistic Updates
RULE: For user-initiated predictable actions (RSVP, toggle). NEVER for server-computed results (Elo, match generation).
const rsvpMutation = useMutation({
mutationFn: (data) => registry.rsvpRepo.respond(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: rsvpKeys.bySession(variables.sessionId) });
const previous = queryClient.getQueryData(rsvpKeys.bySession(variables.sessionId));
queryClient.setQueryData(rsvpKeys.bySession(variables.sessionId), (old: Rsvp[]) =>
old.map((r) => (r.userId === userId ? { ...r, status: variables.status } : r))
);
return { previous };
},
onError: (err, variables, context) => {
queryClient.setQueryData(rsvpKeys.bySession(variables.sessionId), context?.previous);
},
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({ queryKey: rsvpKeys.bySession(variables.sessionId) });
},
});3.5 Background Refetch
RULE: Disable refetchOnWindowFocus. Wire AppState to focusManager. Add useRefreshOnFocus per-screen.
// Wire AppState globally
import { focusManager } from '@tanstack/react-query';
import { AppState, Platform } from 'react-native';
function setupAppFocusListener() {
const sub = AppState.addEventListener('change', (status) => {
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
});
return () => sub.remove();
}
// Per-screen refresh on navigation focus (skip first mount)
function useRefreshOnFocus(queryKey: readonly unknown[]) {
const queryClient = useQueryClient();
const firstTime = useRef(true);
useFocusEffect(
useCallback(() => {
if (firstTime.current) {
firstTime.current = false;
return;
}
queryClient.invalidateQueries({ queryKey, type: 'active' });
}, [queryKey])
);
}3.6 Realtime + TanStack Query Integration — RETIRED
Persistent Realtime is retired (see §1.10). LIVE surfaces use a focus-gated 10s poll that manually invalidates the shared query keys; ON-DEMAND surfaces reconcile via optimistic writes + invalidate-on-mutation + refetch-on-focus. Full freshness-tier framework: Conventions › Networking.