Skip to content

React Native Performance & Memory Management Best Practices

Status: Reference

Research for TwoMore (Expo SDK 55, New Architecture, Hermes) Date: 2026-03-24 Sources: React Native docs, Callstack, Shopify FlashList, Reanimated docs, Expo docs, community guides (2025-2026)


Table of Contents

  1. JS Thread vs UI Thread
  2. Re-render Optimization
  3. FlatList / FlashList Virtualization
  4. Image Optimization
  5. Bundle Size
  6. Startup Time
  7. Hermes Engine
  8. New Architecture (Fabric / TurboModules)
  9. Animation Performance
  10. Bridge Overhead / JSI
  11. Memory Leaks — Common Causes
  12. useEffect Cleanup Patterns
  13. Image Memory
  14. Large List Memory
  15. Zustand / State Store Memory
  16. WebSocket / Realtime Connections
  17. Navigation Memory

PERFORMANCE


1. JS Thread vs UI Thread

RULE: Never run synchronous heavy computation on the JS thread. Offload animations to the UI thread via Reanimated worklets, and defer expensive work with InteractionManager.runAfterInteractions() or requestAnimationFrame.

WHY: React Native has two main threads: the JS thread (runs your React code, business logic, state updates) and the UI thread (renders native views). When the JS thread is blocked (>16ms per frame), the UI thread starves for instructions, causing dropped frames and jank. Touch events, navigation transitions, and animations all suffer.

BAD:

tsx
// Sorting 10,000 items synchronously during render
const SessionList = ({ sessions }: Props) => {
  // This blocks JS thread every render — jank during scroll
  const sorted = sessions
    .filter((s) => s.status === 'upcoming')
    .sort((a, b) => a.date.getTime() - b.date.getTime())
    .map((s) => ({ ...s, label: formatDate(s.date) }));

  return <FlatList data={sorted} renderItem={renderItem} />;
};

GOOD:

tsx
const SessionList = ({ sessions }: Props) => {
  // Memoize expensive computation — only re-runs when sessions changes
  const sorted = useMemo(
    () =>
      sessions
        .filter((s) => s.status === 'upcoming')
        .sort((a, b) => a.date.getTime() - b.date.getTime())
        .map((s) => ({ ...s, label: formatDate(s.date) })),
    [sessions]
  );

  return <FlashList data={sorted} renderItem={renderItem} estimatedItemSize={80} />;
};

// For truly heavy work, defer until after navigation transition
useEffect(() => {
  const handle = InteractionManager.runAfterInteractions(() => {
    computeEloRatings(matchResults);
  });
  return () => handle.cancel();
}, [matchResults]);

TwoMore relevance: Session lists with date grouping, Elo calculations after match entry, rotation engine computation — all should be deferred or memoized. Use InteractionManager after navigation pushes (club detail, match board).


2. Re-render Optimization

RULE: Use React.memo on leaf components that receive stable primitive props or are rendered inside lists. Use useCallback for event handlers passed to memoized children. Use useMemo for expensive derived data. Do NOT wrap every component — over-memoization adds overhead with no benefit.

WHY: React re-renders a component whenever its parent re-renders, regardless of whether props actually changed. In lists of 50+ items, this causes O(n) wasted renders. React.memo does a shallow prop comparison and skips re-render if props are equal. However, memoization has a cost (comparison + cache storage), so it only helps when: (a) the component is expensive to render, or (b) it re-renders frequently with the same props.

BAD:

tsx
// Over-memoization: wrapping everything, including cheap components
const Divider = React.memo(() => <View style={styles.line} />); // Pointless — cheaper to re-render than to compare

// Broken memoization: inline object/function defeats React.memo
const SessionCard = React.memo(({ session, onPress }: Props) => {
  /* ... */
});

// Parent creates new function and object every render
const Parent = () => (
  <SessionCard
    session={{ ...rawSession, extra: 'data' }} // New object every render — memo useless
    onPress={() => handlePress(rawSession.id)} // New function every render — memo useless
  />
);

GOOD:

tsx
// Memo on the list item — renders 50+ times, moderately expensive
const SessionCard = React.memo(({ session, onPress }: Props) => {
  return (
    <Pressable onPress={onPress}>
      <Text>{session.title}</Text>
      <SessionStatusBadge status={session.status} />
    </Pressable>
  );
});

// Parent stabilizes references
const Parent = () => {
  const handlePress = useCallback((id: string) => {
    router.push(routes.sessionDetail(id));
  }, []);

  // Data comes from TanStack Query — already referentially stable between re-fetches
  const { data: sessions } = useClubSessions(clubId);

  return (
    <FlashList
      data={sessions}
      renderItem={({ item }) => <SessionCard session={item} onPress={() => handlePress(item.id)} />}
      estimatedItemSize={80}
    />
  );
};

// No memo needed: simple, cheap, rarely re-renders
const Divider = () => <View className="h-px bg-border" />;

When to memo (decision tree):

  1. Is it a list item rendered 10+ times? -> YES, memo it
  2. Is it an expensive component (images, charts, animations)? -> YES, memo it
  3. Is it a simple leaf with 1-2 primitive props? -> NO, skip it
  4. Does the parent re-render frequently but this child's props don't change? -> YES, memo it

TwoMore relevance: SessionCard, ClubCard, RankingRow, Avatar — all rendered in lists, all should be React.memo. TabHeader, ScreenHeader, Divider, SectionHeader — cheap, skip memo. TanStack Query data is referentially stable (same object between re-renders if data hasn't changed), so query-backed lists get free stability.


3. FlatList / FlashList Virtualization

RULE: Use @shopify/flash-list instead of FlatList for all lists with 20+ items. Always provide estimatedItemSize. Never add key props to the top-level item component. Use getItemType for heterogeneous lists.

WHY: FlatList uses virtualization (mount/unmount items as they enter/leave viewport). FlashList uses cell recycling (keeps a pool of component instances, swaps data). Recycling is dramatically faster: Shopify measured JS thread CPU dropping from >90% to <10%. FlashList also uses less memory because it doesn't create/destroy component trees.

BAD:

tsx
// FlatList with no optimization
<FlatList
  data={members}
  renderItem={({ item }) => <MemberRow member={item} />}
  keyExtractor={item => item.id}
/>

// FlashList with key prop on item — defeats recycling
<FlashList
  data={sessions}
  renderItem={({ item }) => <SessionCard key={item.id} session={item} />}
  estimatedItemSize={80}
/>

// Mixed item types without getItemType — recycler can't optimize
<FlashList
  data={mixedItems}
  renderItem={({ item }) => (
    item.type === 'header' ? <Header {...item} /> : <Row {...item} />
  )}
  estimatedItemSize={60}
/>

GOOD:

tsx
import { FlashList } from '@shopify/flash-list';

<FlashList
  data={members}
  renderItem={({ item }) => <MemberRow member={item} />}
  estimatedItemSize={72}          // Measure once, hardcode
  keyExtractor={item => item.id}  // keyExtractor is fine — it's the `key` JSX prop that breaks recycling
/>

// Heterogeneous list — tell recycler about item types
<FlashList
  data={mixedItems}
  renderItem={({ item }) => (
    item.type === 'header' ? <SectionHeader {...item} /> : <SessionCard {...item} />
  )}
  getItemType={item => item.type}  // Recycler keeps separate pools per type
  estimatedItemSize={80}
/>

// For fixed-height items, provide overrideItemLayout for perfect accuracy
<FlashList
  data={rankings}
  renderItem={({ item }) => <RankingRow {...item} />}
  estimatedItemSize={56}
  overrideItemLayout={(layout) => {
    layout.size = 56; // Exact height — skip measurement entirely
  }}
/>

Key FlashList props:

PropPurposeDefault
estimatedItemSizeInitial size estimate for layoutRequired
getItemTypeReturns string type for recycler poolsundefined
overrideItemLayoutExact size per item (skip measurement)undefined
drawDistancePixels beyond viewport to pre-render250

TwoMore relevance: Session lists, club member lists, ranking leaderboard, activity feed — all should use FlashList. The SessionCard with date grouping headers needs getItemType to separate header cells from session cells. Ranking rows have fixed height — use overrideItemLayout.


4. Image Optimization

RULE: Use expo-image (not RN's Image) for all image rendering. Set explicit width/height. Use cachePolicy="disk". Prefetch images that will appear on the next screen. Use placeholder with blurhash for progressive loading. Serve resized images from the CDN — never download a 2000px image for a 48px avatar.

WHY: RN's core Image has unreliable caching (depends on HTTP headers), blocks the UI thread during resizing, and has no built-in progressive loading. expo-image uses native SDKs (SDWebImage on iOS, Glide on Android) with disk+memory caching, background decoding, and blurhash placeholders.

BAD:

tsx
import { Image } from 'react-native';

// No dimensions — triggers layout recalculation on load
// No caching control — re-downloads on every mount
// Full-size image for a tiny avatar
<Image source={{ uri: user.avatarUrl }} style={{ borderRadius: 24 }} />;

GOOD:

tsx
import { Image } from 'expo-image';

// Avatar — small, fixed size, disk cache, blurhash placeholder
<Image
  source={
    user.avatarUrl ? { uri: `${user.avatarUrl}?w=96&h=96` } : require('@/assets/default-avatar.png')
  }
  style={{ width: 48, height: 48, borderRadius: 24 }}
  cachePolicy="disk"
  placeholder={{ blurhash: user.avatarBlurhash }}
  transition={200}
  recyclingKey={user.id} // Important for FlashList recycling
/>;

// Prefetch images for next screen
import { Image } from 'expo-image';

useEffect(() => {
  if (nextSession?.coverUrl) {
    Image.prefetch(nextSession.coverUrl);
  }
}, [nextSession?.coverUrl]);

expo-image key props:

PropPurpose
cachePolicy"disk" (default), "memory-disk", "memory", "none"
placeholder{ blurhash } or { thumbhash } or local asset
transitionFade-in duration in ms (200-300 recommended)
recyclingKeyStable key for FlashList — prevents image flickering on recycle
contentFit"cover", "contain", "fill" (like resizeMode)

TwoMore relevance: Avatar components (already React.memo), club cover images, session photos. Use recyclingKey={user.id} inside FlashList-rendered member/ranking rows. Request resized images from Supabase Storage: supabase.storage.from('avatars').getPublicUrl(path, { transform: { width: 96, height: 96 } }).


5. Bundle Size

RULE: Use named imports (not barrel imports). Analyze bundle with react-native-bundle-visualizer. Replace heavy libraries with lighter alternatives. Use require() for deferred imports of rarely-used screens. Configure Metro for tree shaking.

WHY: A larger bundle means longer parse time at startup (even with Hermes bytecode). Every KB counts on low-end Android. The top 3-5 dependencies often account for 30-40% of bundle size.

BAD:

tsx
// Barrel import pulls in entire library
import { format, parse, differenceInDays, addDays, subDays, isAfter, isBefore } from 'date-fns';

// Importing entire lodash
import _ from 'lodash';
const sorted = _.sortBy(items, 'date');

// Importing unused icons
import * as Icons from '@expo/vector-icons';

GOOD:

tsx
// Named deep imports — only import what you use
import { format } from 'date-fns/format';
import { differenceInDays } from 'date-fns/differenceInDays';

// Use native Array methods instead of lodash
const sorted = [...items].sort((a, b) => a.date - b.date);

// Single icon import
import { Ionicons } from '@expo/vector-icons';
// Or even better — use a single icon set consistently

// Deferred import for heavy, rarely-used screens
const TournamentBracket = React.lazy(() => import('@/presentation/screens/tournament-bracket'));

Bundle analysis command:

bash
npx react-native-bundle-visualizer
# or for Expo:
npx expo export --dump-sourcemap && npx source-map-explorer dist/bundles/ios-*.js

TwoMore relevance: Check if date-fns is imported via barrel (likely — fix with deep imports). Verify @expo/vector-icons usage — use only one icon family (Ionicons or MaterialIcons, not both). The rotation engine and tournament bracket code should be lazy-loaded since they're only used in match screens.


6. Startup Time

RULE: Keep splash screen visible until the first meaningful screen is ready. Use SplashScreen.preventAutoHideAsync(). Defer non-critical initialization (analytics, push notifications, realtime subscriptions) until after first render. Lazy-load tab screens that aren't immediately visible.

WHY: Users perceive startup time as the interval between tapping the icon and seeing interactive content. Every synchronous import at the top level and every await in the root component adds to Time-To-Interactive (TTI). Hermes bytecode helps, but JS-level deferral is still critical.

BAD:

tsx
// Root layout — everything loads synchronously before any screen renders
import { initializeAnalytics } from '@/lib/analytics';
import { connectRealtime } from '@/lib/realtime';
import { prefetchAllData } from '@/lib/prefetch';
import { loadAllFonts } from '@/lib/fonts';

export default function RootLayout() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    // All of this blocks the splash screen
    Promise.all([initializeAnalytics(), connectRealtime(), prefetchAllData(), loadAllFonts()]).then(
      () => setReady(true)
    );
  }, []);

  if (!ready) return null;
  return <Stack />;
}

GOOD:

tsx
import * as SplashScreen from 'expo-splash-screen';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [fontsLoaded] = useFonts({
    /* only critical fonts */
  });
  const themeHydrated = useThemeStore((s) => s._hasHydrated);

  useEffect(() => {
    if (fontsLoaded && themeHydrated) {
      SplashScreen.hideAsync();

      // Defer non-critical init AFTER splash hides
      InteractionManager.runAfterInteractions(() => {
        initializeAnalytics();
        connectRealtime();
      });
    }
  }, [fontsLoaded, themeHydrated]);

  if (!fontsLoaded || !themeHydrated) return null;
  return <Stack />;
}

// In tab layout — lazy load non-visible tabs
<Tabs>
  <Tabs.Screen name="(home)" options={{ lazy: true }} />
  <Tabs.Screen name="(clubs)" options={{ lazy: true }} />
  <Tabs.Screen name="(activity)" options={{ lazy: true }} />
  <Tabs.Screen name="(profile)" options={{ lazy: true }} />
</Tabs>;

TwoMore relevance: Already using SplashScreen.preventAutoHideAsync() + _hasHydrated for theme. Verify that analytics, Supabase Realtime, and push notification registration are deferred to InteractionManager.runAfterInteractions. Tab screens should have lazy: true (Expo Router supports this).


7. Hermes Engine

RULE: Ensure Hermes is enabled (default in Expo SDK 55). Avoid polyfills for features Hermes already supports. Use Intl polyfill only if needed (Hermes V1 in RN 0.82+ has native Intl). Avoid eval() and new Function() — Hermes doesn't support them. Prefer Date over Temporal (not yet in Hermes).

WHY: Hermes compiles JS to bytecode at build time (AOT), reducing startup parse time by 30-50% and memory by 20-30%. Hermes V1 (RN 0.82+, Oct 2025) adds native support for class fields, async functions, top-level await, and Intl, with up to 60% improvement on synthetic benchmarks.

BAD:

tsx
// Using eval — Hermes doesn't support it
const result = eval('2 + 2');

// Unnecessary Intl polyfill — Hermes V1 has native Intl
import 'intl';
import 'intl/locale-data/jsonp/ko';

// Using Proxy extensively — performance is poor in Hermes
const reactive = new Proxy(state, handler); // Avoid in hot paths

GOOD:

tsx
// Hermes-friendly date formatting
const formatted = new Intl.DateTimeFormat('ko', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
}).format(date);

// Use Zod (TwoMore's validation library) instead of runtime eval
const schema = z.object({ score: z.number().min(0).max(21) });

// Check Hermes at runtime if needed
const isHermes = () => !!global.HermesInternal;

TwoMore relevance: Verify no eval() or new Function() usage. Check if Intl polyfills are still imported (can be removed with Hermes V1). Zod schemas are Hermes-friendly. date-fns works well with Hermes — no special treatment needed.


8. New Architecture (Fabric / TurboModules)

RULE: Enable New Architecture in app.json ("newArchEnabled": true). Verify all third-party libraries are compatible. Use Fabric's concurrent features (automatic batching, transitions). Leverage TurboModules for lazy native module loading.

WHY: The New Architecture replaces the async JSON bridge with JSI (JavaScript Interface) — a synchronous C++ layer that lets JS hold direct references to native objects. This eliminates serialization overhead, enables concurrent rendering (Fabric), and lazy-loads native modules (TurboModules) for faster startup.

BAD:

tsx
// Old architecture pattern — synchronous bridge calls block
import { NativeModules } from 'react-native';
const result = NativeModules.MyModule.computeSync(); // Bridge serialization

// Forcing all native modules to load at startup
// (Old arch loads ALL NativeModules on startup, even unused ones)

GOOD:

tsx
// New Architecture — TurboModules load lazily, only when first accessed
// Most Expo modules already support this — no action needed for Expo SDK 55

// app.json — enable New Architecture
{
  "expo": {
    "plugins": [
      ["expo-build-properties", {
        "android": { "newArchEnabled": true },
        "ios": { "newArchEnabled": true }
      }]
    ]
  }
}

// Verify library compatibility before enabling
// npx react-native-new-arch-helper check

Key New Architecture benefits:

FeatureOld ArchNew Arch
JS-Native communicationAsync JSON bridgeSynchronous JSI
Native module loadingAll at startupLazy (TurboModules)
RenderingSingle-threadedConcurrent (Fabric)
SerializationJSON encode/decodeDirect memory access
Type safetyRuntime onlyCodegen at build time

TwoMore relevance: Expo SDK 55 supports New Architecture. Verify all dependencies are compatible: @shopify/flash-list, react-native-reanimated, nativewind, @react-native-kakao/*. Enable in app.json via expo-build-properties plugin. Test thoroughly on Android (historically more issues than iOS).


9. Animation Performance

RULE: Use react-native-reanimated (v4) for all animations. Write animation logic in worklets (runs on UI thread). Never read SharedValue on the JS thread. Use useAnimatedStyle instead of inline animated styles. Limit simultaneous animated components to ~100 (Android) / ~500 (iOS).

WHY: Reanimated worklets execute on the UI thread, achieving 60-120 FPS regardless of JS thread load. The old Animated API calculates keyframes on the JS thread (unless useNativeDriver: true, which only supports transform and opacity). Reanimated supports arbitrary style properties on the UI thread.

BAD:

tsx
import { Animated } from 'react-native';

// Old Animated API — runs on JS thread, causes jank
const opacity = useRef(new Animated.Value(0)).current;
Animated.timing(opacity, {
  toValue: 1,
  duration: 300,
  useNativeDriver: false, // Runs on JS thread!
}).start();

// Reading shared value on JS thread
import { useSharedValue } from 'react-native-reanimated';
const sv = useSharedValue(0);
console.log(sv.value); // BAD — reads on JS thread, triggers bridge

GOOD:

tsx
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  withTiming,
  runOnJS,
} from 'react-native-reanimated';

const CardWithPress = () => {
  const scale = useSharedValue(1);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));

  const onPressIn = () => {
    // withSpring runs entirely on UI thread
    scale.value = withSpring(0.96, { damping: 20, stiffness: 400 });
  };

  const onPressOut = () => {
    scale.value = withSpring(1, { damping: 15, stiffness: 300 });
  };

  return (
    <Pressable onPressIn={onPressIn} onPressOut={onPressOut}>
      <Animated.View style={animatedStyle}>{/* Card content */}</Animated.View>
    </Pressable>
  );
};

// If you MUST call JS from a worklet, use runOnJS
const onComplete = () => {
  'worklet';
  runOnJS(navigateToNext)();
};

Reanimated performance rules:

  1. Keep worklets minimal — no heavy computation
  2. Use useDerivedValue to share computed animation values (not recalculate in multiple places)
  3. Use cancelAnimation(sharedValue) in cleanup to prevent orphaned animations
  4. Clamp interpolations to avoid unexpected values
  5. Don't call runOnJS inside high-frequency gesture callbacks

TwoMore relevance: Already using withSpring for card press animations (damping 20/15, stiffness 400/300). FAB, EmptyState breathe animation, SessionStatusBadge pulse — all should use Reanimated worklets. Verify no Animated from react-native is used anywhere (replace with Reanimated's Animated).


10. Bridge Overhead / JSI

RULE: With New Architecture enabled, the bridge is replaced by JSI — minimal action needed. For old architecture: batch state updates, minimize JS-to-native calls per frame, use unstable_batchedUpdates for multiple setState calls in event handlers.

WHY: The old bridge serializes every JS<->Native message to JSON. Each call costs ~0.5-1ms. At 60 FPS you have 16ms budget — 10+ bridge calls per frame causes jank. JSI (New Architecture) eliminates this by allowing synchronous C++ function calls.

BAD (Old Architecture):

tsx
// Multiple separate state updates — each triggers a bridge message
const onMatchComplete = (result: MatchResult) => {
  setScore(result.score); // Bridge call 1
  setWinner(result.winner); // Bridge call 2
  setEloChange(result.eloChange); // Bridge call 3
  setStatus('completed'); // Bridge call 4
};

GOOD:

tsx
// Batch updates (React 18+ auto-batches in event handlers, but not in async callbacks)
import { unstable_batchedUpdates } from 'react-native';

const onMatchComplete = async (result: MatchResult) => {
  const eloChange = await calculateElo(result);

  // In async callback — wrap in batchedUpdates
  unstable_batchedUpdates(() => {
    setScore(result.score);
    setWinner(result.winner);
    setEloChange(eloChange);
    setStatus('completed');
  });
};

// Even better — use a single state object or Zustand
const useMatchStore = create<MatchState>((set) => ({
  completeMatch: (result: MatchResult) =>
    set({
      score: result.score,
      winner: result.winner,
      eloChange: result.eloChange,
      status: 'completed',
    }), // Single update
}));

TwoMore relevance: With Zustand stores, single set() calls already batch. TanStack Query's queryClient.setQueryData is a single operation. Main concern is manual useState in screens — batch async state updates. With New Architecture (JSI), this becomes less critical but still good practice.


MEMORY MANAGEMENT


11. Memory Leaks — Common Causes

RULE: Every subscription, listener, timer, and async operation created in a component MUST be cleaned up on unmount. Use the "create in useEffect, clean in return" pattern. Track leaks with Flipper's Hermes heap profiler.

WHY: Unlike web browsers that aggressively garbage-collect, React Native's JS runtime retains references to closures, timers, and listeners. A leaked setInterval on a screen visited 10 times means 10 intervals running simultaneously, consuming CPU and memory.

Common leak sources:

  1. setInterval / setTimeout not cleared
  2. Event listeners (AppState, Keyboard, Dimensions) not removed
  3. Supabase Realtime subscriptions not unsubscribed
  4. TanStack Query observers not cleaned up (usually automatic, but custom subscriptions aren't)
  5. Animated values not cancelled
  6. AbortController not aborted for fetch requests
  7. Zustand subscribe() not unsubscribed

BAD:

tsx
useEffect(() => {
  // Timer leak — never cleared
  setInterval(() => refreshData(), 30000);

  // Listener leak — never removed
  AppState.addEventListener('change', handleAppState);

  // Supabase subscription leak — never unsubscribed
  supabase
    .channel('sessions')
    .on(
      'postgres_changes',
      {
        /* ... */
      },
      handleChange
    )
    .subscribe();

  // Fetch without abort — continues after unmount
  fetch('/api/data')
    .then((r) => r.json())
    .then(setData);
}, []);

GOOD:

tsx
useEffect(() => {
  // Timer — clear on unmount
  const intervalId = setInterval(() => refreshData(), 30000);

  // Listener — remove on unmount
  const subscription = AppState.addEventListener('change', handleAppState);

  // Supabase — unsubscribe on unmount
  const channel = supabase
    .channel('sessions')
    .on('postgres_changes', { event: '*', schema: 'public', table: 'sessions' }, handleChange)
    .subscribe();

  // Fetch — abort on unmount
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then((r) => r.json())
    .then(setData)
    .catch((e) => {
      if (e.name !== 'AbortError') throw e;
    });

  return () => {
    clearInterval(intervalId);
    subscription.remove();
    supabase.removeChannel(channel);
    controller.abort();
  };
}, []);

TwoMore relevance: Supabase Realtime channels are the highest risk — ensure every .subscribe() has a matching supabase.removeChannel() in cleanup. Check useClubSessions, useSessionApplications, and any real-time hooks. Zustand's subscribe() in effects must also be cleaned up.


12. useEffect Cleanup Patterns

RULE: Return a cleanup function from EVERY useEffect that creates a side effect. For async effects, use an AbortController or a cancelled flag. Never use async directly in useEffect — create an inner async function.

WHY: React calls the cleanup function: (a) when the component unmounts, and (b) before re-running the effect when dependencies change. Without cleanup, the old effect's side effects overlap with the new one.

BAD:

tsx
// async directly in useEffect — cleanup function is a Promise, not a function
useEffect(async () => {
  const data = await fetchSessions(clubId);
  setSessions(data); // May run after unmount
}, [clubId]);

// No cleanup for dependency change — stale data from old clubId
useEffect(() => {
  let cancel = false;
  fetchSessions(clubId).then((data) => {
    setSessions(data); // May set data from old clubId!
  });
}, [clubId]);

GOOD:

tsx
// Pattern 1: AbortController (preferred for fetch)
useEffect(() => {
  const controller = new AbortController();

  const load = async () => {
    try {
      const data = await fetchSessions(clubId, { signal: controller.signal });
      setSessions(data);
    } catch (e) {
      if (!controller.signal.aborted) {
        console.error('Failed to fetch sessions:', e);
      }
    }
  };

  load();
  return () => controller.abort();
}, [clubId]);

// Pattern 2: cancelled flag (for non-fetch async)
useEffect(() => {
  let cancelled = false;

  computeEloRatings(matches).then((ratings) => {
    if (!cancelled) setRatings(ratings);
  });

  return () => {
    cancelled = true;
  };
}, [matches]);

// Pattern 3: TanStack Query (best — handles all of this automatically)
const { data: sessions } = useQuery({
  queryKey: queryKeys.sessions.byClub(clubId),
  queryFn: ({ signal }) => sessionRepo.findByClub(clubId, { signal }),
  // Automatic cleanup, caching, deduplication, and refetching
});

TwoMore relevance: TanStack Query handles most data fetching — cleanup is automatic. Manual useEffects in presentation hooks (e.g., useHideTabBar, theme hydration timeout) must all have cleanup returns. Verify every useEffect in src/presentation/hooks/ returns a cleanup function or has no side effects.


13. Image Memory

RULE: Use expo-image with cachePolicy="disk" (not "memory" for large collections). Set recyclingKey in FlashList items. Never load full-resolution images for thumbnails — request resized versions from Supabase Storage transforms. Clear image cache periodically for long-running sessions.

WHY: Each decoded image in memory consumes width * height * 4 bytes (RGBA). A single 1000x1000 image = 4MB in memory. A list of 50 such images = 200MB — enough to trigger OOM on low-end Android (1-2GB RAM). Disk caching keeps decoded images on disk and only decodes visible ones.

BAD:

tsx
// Full-resolution avatars in a list — 50 * 4MB = 200MB
<FlashList
  data={members}
  renderItem={({ item }) => (
    <Image
      source={{ uri: item.avatarUrl }} // Full 1000x1000 image
      style={{ width: 48, height: 48 }}
      cachePolicy="memory" // All 50 stay in RAM
    />
  )}
/>

GOOD:

tsx
// Resized avatars with disk cache
<FlashList
  data={members}
  renderItem={({ item }) => (
    <Image
      source={{
        uri: item.avatarUrl ? `${item.avatarUrl}?width=96&height=96&resize=cover` : undefined,
      }}
      style={{ width: 48, height: 48, borderRadius: 24 }}
      cachePolicy="disk" // Decoded on demand, not all in RAM
      recyclingKey={item.id} // Prevents flash when FlashList recycles
      placeholder={AVATAR_BLURHASH}
      transition={150}
    />
  )}
  estimatedItemSize={72}
/>;

// Periodic cache clearing (e.g., on app background after long session)
import { Image } from 'expo-image';

const handleAppBackground = () => {
  if (sessionDuration > 30 * 60 * 1000) {
    // 30 minutes
    Image.clearDiskCache();
  }
};

Size budget:

ContextMax image dimensionsMemory per image
Avatar (list)96x9636KB
Avatar (profile)200x200160KB
Card thumbnail300x200240KB
Full screen heroDevice width x 300~1.4MB (390x300)

TwoMore relevance: Avatar component is used in member lists, ranking rows, session RSVP lists. All should request ?width=96&height=96 from Supabase Storage. Profile screen avatar can use ?width=200&height=200. Use recyclingKey in all FlashList-rendered images.


14. Large List Memory

RULE: Use FlashList (recycling) over FlatList (mount/unmount). Set drawDistance to 250-500px (not 1000+). For very long lists (1000+ items), implement pagination with TanStack Query's useInfiniteQuery. Never render all items at once.

WHY: FlatList's removeClippedSubviews unmounts off-screen components but keeps their JS objects in memory. FlashList's recycling reuses the same component instances, keeping memory flat regardless of list length. drawDistance controls how far ahead to pre-render — too large wastes memory, too small causes blank flashes.

BAD:

tsx
// Rendering all items in a ScrollView — O(n) memory
<ScrollView>
  {sessions.map(s => <SessionCard key={s.id} session={s} />)}
</ScrollView>

// FlatList with excessive window
<FlatList
  data={allMembers}     // 500 members loaded at once
  windowSize={21}       // Default — renders 21 screens worth of items
  maxToRenderPerBatch={50} // Renders 50 items per batch — jank
/>

GOOD:

tsx
// FlashList with controlled draw distance
<FlashList
  data={members}
  renderItem={({ item }) => <MemberRow member={item} />}
  estimatedItemSize={72}
  drawDistance={300} // Pre-render 300px ahead — balance between smoothness and memory
/>;

// Infinite pagination for very long lists
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: queryKeys.rankings.global(),
  queryFn: ({ pageParam = 0, signal }) =>
    rankingRepo.getGlobalRankings({ offset: pageParam, limit: 20, signal }),
  getNextPageParam: (lastPage, allPages) =>
    lastPage.length === 20 ? allPages.length * 20 : undefined,
});

const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);

<FlashList
  data={flatData}
  renderItem={({ item }) => <RankingRow {...item} />}
  estimatedItemSize={56}
  onEndReached={() => hasNextPage && fetchNextPage()}
  onEndReachedThreshold={0.5}
  ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>;

TwoMore relevance: Club member lists (could grow to 100+), global ranking (could be 1000+), activity feed. Ranking screen should use useInfiniteQuery with pagination. Session lists are typically small (<50) — FlashList with basic config is sufficient. The activity feed with filters should paginate.


15. Zustand / State Store Memory

RULE: Keep Zustand stores focused and small. Never store server data in Zustand — use TanStack Query for that. Clear transient state when leaving a screen. Use subscribeWithSelector to avoid unnecessary re-renders. Zustand persisted stores should have a version and migrate function.

WHY: Zustand stores are singletons — they live for the entire app session. If you store fetched data in Zustand, it never gets garbage-collected (unlike TanStack Query's gcTime). Selectors that return new objects/arrays on every call cause infinite re-renders.

BAD:

tsx
// Storing server data in Zustand — never garbage-collected
const useSessionStore = create<SessionStore>((set) => ({
  sessions: [],
  fetchSessions: async (clubId: string) => {
    const data = await sessionRepo.findByClub(clubId);
    set({ sessions: data }); // Stays in memory forever
  },
}));

// Selector that creates new array every time — causes re-render loop
const sessions = useSessionStore(
  (state) => state.sessions.filter((s) => s.status === 'upcoming') // New array every call!
);

// Persisted store without version — breaks on schema change
const useThemeStore = create(persist((set) => ({ themeId: 'classic_grass' }), { name: 'theme' }));

GOOD:

tsx
// Server data in TanStack Query — auto garbage-collected after gcTime
const { data: sessions } = useQuery({
  queryKey: queryKeys.sessions.byClub(clubId),
  queryFn: () => sessionRepo.findByClub(clubId),
  gcTime: 5 * 60 * 1000, // Garbage-collected 5min after last observer unmounts
});

// Zustand only for CLIENT state (UI preferences, auth token, theme)
const useThemeStore = create(
  persist<ThemeState>(
    (set) => ({
      themeId: 'classic_grass',
      _hasHydrated: false,
      setTheme: (id: string) => set({ themeId: id }),
      setHasHydrated: (v: boolean) => set({ _hasHydrated: v }),
    }),
    {
      name: 'theme-storage',
      version: 1,
      migrate: (persisted, version) => {
        // Handle schema changes
        if (version === 0) return { ...persisted, themeId: 'classic_grass' };
        return persisted as ThemeState;
      },
    }
  )
);

// Stable selector — returns primitive, no re-render loop
const themeId = useThemeStore((state) => state.themeId);

// If you must derive, memoize externally
const upcomingSessions = useMemo(
  () => sessions?.filter((s) => s.status === 'upcoming') ?? [],
  [sessions]
);

// Clean up transient store state on screen leave
useFocusEffect(
  useCallback(() => {
    return () => {
      useMatchFormStore.getState().reset(); // Clear form data on leave
    };
  }, [])
);

TwoMore relevance: ThemeStore already has _hasHydrated and persist — add version and migrate. Verify no server data (sessions, clubs, members) is stored in Zustand — all should flow through TanStack Query. Any form stores (match scoring, RSVP) should reset on screen blur/unmount.


16. WebSocket / Realtime Connections

RULE: Create Supabase Realtime channels at the screen level (not globally). Unsubscribe in useEffect cleanup. Use AppState to disconnect on background and reconnect on foreground. Limit to 1-2 active channels at a time. Use TanStack Query's refetchOnWindowFocus as a fallback for missed events.

WHY: Each Realtime channel maintains a WebSocket connection with heartbeat. Background channels consume battery and data. Supabase has limits (100 concurrent connections per project on free tier). Silent disconnections happen when apps are backgrounded — the channel appears connected but misses events.

BAD:

tsx
// Global subscription — lives forever, even on unrelated screens
// In app/_layout.tsx
useEffect(() => {
  const channel = supabase
    .channel('all-changes')
    .on('postgres_changes', { event: '*', schema: 'public' }, handleAny)
    .subscribe();
  // No cleanup!
}, []);

// Multiple overlapping subscriptions
const SessionDetail = ({ sessionId }) => {
  useEffect(() => {
    supabase.channel(`session-${sessionId}`).on(/* ... */).subscribe();
    supabase.channel(`rsvp-${sessionId}`).on(/* ... */).subscribe();
    supabase.channel(`matches-${sessionId}`).on(/* ... */).subscribe();
    // 3 channels for 1 screen, no cleanup
  }, [sessionId]);
};

GOOD:

tsx
// Screen-scoped subscription with full lifecycle management
const useRealtimeSession = (sessionId: string) => {
  const queryClient = useQueryClient();

  useEffect(() => {
    const channel = supabase
      .channel(`session-${sessionId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'sessions',
          filter: `id=eq.${sessionId}`,
        },
        (payload) => {
          // Invalidate TanStack Query cache — let it refetch
          queryClient.invalidateQueries({
            queryKey: queryKeys.sessions.detail(sessionId),
          });
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [sessionId, queryClient]);
};

// App state management for connection lifecycle
const useRealtimeLifecycle = () => {
  useEffect(() => {
    const subscription = AppState.addEventListener('change', (state) => {
      if (state === 'background') {
        supabase.removeAllChannels();
      }
      if (state === 'active') {
        // Channels will be re-created by screen-level hooks on re-focus
        // TanStack Query refetches stale queries automatically
      }
    });

    return () => subscription.remove();
  }, []);
};

TwoMore relevance: Supabase Realtime is used for session updates and RSVP changes. Create a useRealtimeSession hook that subscribes only on the session detail screen and cleans up on unmount. Use queryClient.invalidateQueries instead of manually updating state. Add useRealtimeLifecycle in the root layout to manage background/foreground transitions.


17. Navigation Memory

RULE: Use lazy: true for all tab screens. Use detachInactiveScreens: true (default in react-native-screens). Don't keep heavy state in screens that are retained in the navigation stack. Use useFocusEffect for screen-specific data loading (not useEffect).

WHY: In a stack navigator, previous screens are retained in memory (for back navigation). In a tab navigator, all visited tabs stay mounted. Without lazy, all tabs mount on first render. Without detachInactiveScreens, native views for off-screen tabs consume memory.

BAD:

tsx
// Tab layout without lazy — all 4 tabs render on app start
<Tabs>
  <Tabs.Screen name="home" />
  <Tabs.Screen name="clubs" />
  <Tabs.Screen name="activity" />
  <Tabs.Screen name="profile" />
</Tabs>;

// Screen with data loading in useEffect — loads even when not focused
const ClubDashboard = () => {
  useEffect(() => {
    fetchClubData(); // Runs on mount AND stays running when user switches tabs
  }, []);
};

// Accumulating state across navigations
const SessionDetail = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  // Messages accumulate every time user visits — never cleared
};

GOOD:

tsx
// Lazy tabs — only render when first navigated to
<Tabs
  screenOptions={{
    lazy: true,
    // react-native-screens detaches native views by default
  }}
>
  <Tabs.Screen name="(home)" />
  <Tabs.Screen name="(clubs)" />
  <Tabs.Screen name="(activity)" />
  <Tabs.Screen name="(profile)" />
</Tabs>

// Load data only when screen is focused
import { useFocusEffect } from 'expo-router';

const ClubDashboard = ({ clubId }: Props) => {
  // TanStack Query + useFocusEffect for refetch on focus
  const { data } = useClubDashboard(clubId);

  useFocusEffect(
    useCallback(() => {
      // Refetch stale data when screen comes into focus
      queryClient.invalidateQueries({
        queryKey: queryKeys.clubs.dashboard(clubId),
        // Only refetch if stale
        type: 'active',
      });

      return () => {
        // Clean up screen-specific resources on blur
      };
    }, [clubId]),
  );
};

// For Expo Router — configure async routes for code splitting (experimental)
// app.json
{
  "expo": {
    "experiments": {
      "asyncRoutes": true  // Enables React.lazy for route components
    }
  }
}

Expo Router specific:

  • useFocusEffect from expo-router works like useEffect but only when screen is focused
  • Stack screens are retained by default — use router.replace() instead of router.push() when you don't need back navigation
  • Tab screens with lazy: true don't render until first visit
  • unmountOnBlur: true in tab options fully unmounts tab on switch (use sparingly — loses scroll position)

TwoMore relevance: Verify tab layout has lazy: true. Club detail screens pushed above tabs should clean up Realtime subscriptions on blur. Match board screen (hides tab bar via useHideTabBar) should use useFocusEffect for realtime match updates. The router.replace() pattern applies to the auth flow — after login, replace the auth screen so it's not in the back stack.


Summary Checklist

Quick Wins (implement immediately)

  • [ ] Replace all FlatList with FlashList + estimatedItemSize
  • [ ] Add lazy: true to all tab screens
  • [ ] Add recyclingKey to all expo-image in lists
  • [ ] Verify every useEffect has a cleanup return
  • [ ] Remove key prop from FlashList item components
  • [ ] Use InteractionManager.runAfterInteractions for post-navigation work

Medium Effort

  • [ ] Replace Image from react-native with expo-image everywhere
  • [ ] Request resized images from Supabase Storage (width/height params)
  • [ ] Add getItemType to heterogeneous FlashLists (session list with date headers)
  • [ ] Create useRealtimeSession hook with proper channel lifecycle
  • [ ] Add version + migrate to persisted Zustand stores
  • [ ] Implement useRealtimeLifecycle for background/foreground connection management

Architecture Level

  • [ ] Enable New Architecture in app.json (test all dependencies first)
  • [ ] Implement infinite pagination for ranking and activity screens
  • [ ] Enable Expo Router async routes for code splitting
  • [ ] Set up bundle analysis in CI pipeline
  • [ ] Profile with Flipper Hermes heap snapshots on low-end Android

Sources

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