Skip to content

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

PriorityActionWhere
P0Wire onlineManager to expo-networkapp/_layout.tsx
P0Wire focusManager to AppStateapp/_layout.tsx
P0Set refetchOnWindowFocus: false globallyQueryClient config
P1Thread AbortSignal through Supabase adapterssrc/adapters/supabase/
P1Add typed AppError hierarchysrc/domain/errors/
P1Implement optimistic RSVP mutationsrc/presentation/hooks/
P1Realtime RETIRED → focus-gated polling (§1.10)presentation/hooks/composites/
P1Add useRefreshOnFocus hooksrc/presentation/hooks/
P2MMKV persistence for TanStack Queryapp/_layout.tsx
P2Prefetch on list item renderClubCard, SessionCard
P2Add lists()/details() intermediate keysquery-keys.ts
P2Cursor-based infinite queries for historySession/match history hooks
P3Mutation persistence for offlinePersistQueryClientProvider

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.

typescript
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.

typescript
// 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.

typescript
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.

typescript
// 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.

typescript
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.

typescript
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.

typescript
// 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.

typescript
// 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 native refetchInterval (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.

DatastaleTimegcTime
RSVPs, live scores30s (focus-poll)10 min
Player stats2 min (frequent)15 min
Sessions, members5 min (standard)30 min
Profiles, clubs15 min (stable)60 min
typescript
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.

typescript
// 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 detail

2.3 Persistent Cache — MMKV

RULE: Use MMKV for TanStack Query persistence (~30x faster than AsyncStorage). Only persist stable queries.

typescript
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.

typescript
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.

CardinalityLimitExamples
High (events, matches, transactions)200match history, session history
Medium (members, alerts, dues)100club members, notifications
Low (clubs, leaderboards)50club list, rankings
typescript
// 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.

typescript
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.

typescript
// 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).

typescript
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.

typescript
// 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.


Sources

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