@0xsown/vibe-code-fe 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +181 -0
- package/package.json +32 -0
- package/skills/claude-md-improver/SKILL.md +179 -0
- package/skills/claude-md-improver/references/quality-criteria.md +109 -0
- package/skills/claude-md-improver/references/templates.md +253 -0
- package/skills/claude-md-improver/references/update-guidelines.md +150 -0
- package/skills/find-skills/SKILL.md +133 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/next-best-practices/SKILL.md +153 -0
- package/skills/next-best-practices/async-patterns.md +87 -0
- package/skills/next-best-practices/bundling.md +180 -0
- package/skills/next-best-practices/data-patterns.md +297 -0
- package/skills/next-best-practices/debug-tricks.md +105 -0
- package/skills/next-best-practices/directives.md +73 -0
- package/skills/next-best-practices/error-handling.md +227 -0
- package/skills/next-best-practices/file-conventions.md +140 -0
- package/skills/next-best-practices/font.md +245 -0
- package/skills/next-best-practices/functions.md +108 -0
- package/skills/next-best-practices/hydration-error.md +91 -0
- package/skills/next-best-practices/image.md +173 -0
- package/skills/next-best-practices/metadata.md +301 -0
- package/skills/next-best-practices/parallel-routes.md +287 -0
- package/skills/next-best-practices/route-handlers.md +146 -0
- package/skills/next-best-practices/rsc-boundaries.md +159 -0
- package/skills/next-best-practices/runtime-selection.md +39 -0
- package/skills/next-best-practices/scripts.md +141 -0
- package/skills/next-best-practices/self-hosting.md +371 -0
- package/skills/next-best-practices/suspense-boundaries.md +67 -0
- package/skills/next-cache-components/SKILL.md +411 -0
- package/skills/shadcn-ui/README.md +248 -0
- package/skills/shadcn-ui/SKILL.md +326 -0
- package/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
- package/skills/shadcn-ui/examples/data-table.tsx +313 -0
- package/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
- package/skills/shadcn-ui/resources/component-catalog.md +481 -0
- package/skills/shadcn-ui/resources/customization-guide.md +516 -0
- package/skills/shadcn-ui/resources/migration-guide.md +463 -0
- package/skills/shadcn-ui/resources/setup-guide.md +412 -0
- package/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
- package/skills/supabase-postgres-best-practices/AGENTS.md +68 -0
- package/skills/supabase-postgres-best-practices/CLAUDE.md +68 -0
- package/skills/supabase-postgres-best-practices/README.md +116 -0
- package/skills/supabase-postgres-best-practices/SKILL.md +64 -0
- package/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
- package/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
- package/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
- package/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
- package/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
- package/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
- package/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
- package/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
- package/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
- package/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
- package/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
- package/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
- package/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
- package/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
- package/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
- package/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
- package/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
- package/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
- package/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
- package/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
- package/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
- package/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
- package/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
- package/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
- package/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
- package/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
- package/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
- package/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
- package/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
- package/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
- package/skills/supabase-postgres-best-practices/references/security-rls-performance.md +57 -0
- package/skills/tailwind-design-system/SKILL.md +874 -0
- package/skills/vercel-composition-patterns/AGENTS.md +946 -0
- package/skills/vercel-composition-patterns/README.md +60 -0
- package/skills/vercel-composition-patterns/SKILL.md +89 -0
- package/skills/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md +100 -0
- package/skills/vercel-composition-patterns/rules/architecture-compound-components.md +112 -0
- package/skills/vercel-composition-patterns/rules/patterns-children-over-render-props.md +87 -0
- package/skills/vercel-composition-patterns/rules/patterns-explicit-variants.md +100 -0
- package/skills/vercel-composition-patterns/rules/react19-no-forwardref.md +42 -0
- package/skills/vercel-composition-patterns/rules/state-context-interface.md +191 -0
- package/skills/vercel-composition-patterns/rules/state-decouple-implementation.md +113 -0
- package/skills/vercel-composition-patterns/rules/state-lift-state.md +125 -0
- package/skills/vercel-react-best-practices/AGENTS.md +2934 -0
- package/skills/vercel-react-best-practices/README.md +123 -0
- package/skills/vercel-react-best-practices/SKILL.md +136 -0
- package/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
- package/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
- package/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
- package/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
- package/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
- package/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
- package/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
- package/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
- package/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
- package/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
- package/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
- package/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
- package/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
- package/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
- package/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
- package/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
- package/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
- package/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
- package/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
- package/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
- package/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
- package/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
- package/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
- package/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
- package/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
- package/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
- package/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
- package/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
- package/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
- package/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
- package/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
- package/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
- package/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
- package/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
- package/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
- package/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
- package/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
- package/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
- package/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
- package/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
- package/skills/vercel-react-native-skills/AGENTS.md +2897 -0
- package/skills/vercel-react-native-skills/README.md +165 -0
- package/skills/vercel-react-native-skills/SKILL.md +121 -0
- package/skills/vercel-react-native-skills/rules/animation-derived-value.md +53 -0
- package/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md +95 -0
- package/skills/vercel-react-native-skills/rules/animation-gpu-properties.md +65 -0
- package/skills/vercel-react-native-skills/rules/design-system-compound-components.md +66 -0
- package/skills/vercel-react-native-skills/rules/fonts-config-plugin.md +71 -0
- package/skills/vercel-react-native-skills/rules/imports-design-system-folder.md +68 -0
- package/skills/vercel-react-native-skills/rules/js-hoist-intl.md +61 -0
- package/skills/vercel-react-native-skills/rules/list-performance-callbacks.md +44 -0
- package/skills/vercel-react-native-skills/rules/list-performance-function-references.md +132 -0
- package/skills/vercel-react-native-skills/rules/list-performance-images.md +53 -0
- package/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md +97 -0
- package/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md +94 -0
- package/skills/vercel-react-native-skills/rules/list-performance-item-memo.md +82 -0
- package/skills/vercel-react-native-skills/rules/list-performance-item-types.md +104 -0
- package/skills/vercel-react-native-skills/rules/list-performance-virtualize.md +67 -0
- package/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md +46 -0
- package/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md +63 -0
- package/skills/vercel-react-native-skills/rules/navigation-native-navigators.md +188 -0
- package/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md +50 -0
- package/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md +48 -0
- package/skills/vercel-react-native-skills/rules/react-state-dispatcher.md +91 -0
- package/skills/vercel-react-native-skills/rules/react-state-fallback.md +56 -0
- package/skills/vercel-react-native-skills/rules/react-state-minimize.md +65 -0
- package/skills/vercel-react-native-skills/rules/rendering-no-falsy-and.md +74 -0
- package/skills/vercel-react-native-skills/rules/rendering-text-in-text-component.md +36 -0
- package/skills/vercel-react-native-skills/rules/scroll-position-no-state.md +82 -0
- package/skills/vercel-react-native-skills/rules/state-ground-truth.md +80 -0
- package/skills/vercel-react-native-skills/rules/ui-expo-image.md +66 -0
- package/skills/vercel-react-native-skills/rules/ui-image-gallery.md +104 -0
- package/skills/vercel-react-native-skills/rules/ui-measure-views.md +78 -0
- package/skills/vercel-react-native-skills/rules/ui-menus.md +174 -0
- package/skills/vercel-react-native-skills/rules/ui-native-modals.md +77 -0
- package/skills/vercel-react-native-skills/rules/ui-pressable.md +61 -0
- package/skills/vercel-react-native-skills/rules/ui-safe-area-scroll.md +65 -0
- package/skills/vercel-react-native-skills/rules/ui-scrollview-content-inset.md +45 -0
- package/skills/vercel-react-native-skills/rules/ui-styling.md +87 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/templates/AGENTS.md +31 -0
- package/templates/CLAUDE.md +31 -0
|
@@ -0,0 +1,2897 @@
|
|
|
1
|
+
# React Native Skills
|
|
2
|
+
|
|
3
|
+
**Version 1.0.0**
|
|
4
|
+
Engineering
|
|
5
|
+
January 2026
|
|
6
|
+
|
|
7
|
+
> **Note:**
|
|
8
|
+
> This document is mainly for agents and LLMs to follow when maintaining,
|
|
9
|
+
> generating, or refactoring React Native codebases. Humans
|
|
10
|
+
> may also find it useful, but guidance here is optimized for automation
|
|
11
|
+
> and consistency by AI-assisted workflows.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Abstract
|
|
16
|
+
|
|
17
|
+
Comprehensive performance optimization guide for React Native applications, designed for AI agents and LLMs. Contains 35+ rules across 13 categories, prioritized by impact from critical (core rendering, list performance) to incremental (fonts, imports). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Table of Contents
|
|
22
|
+
|
|
23
|
+
1. [Core Rendering](#1-core-rendering) — **CRITICAL**
|
|
24
|
+
- 1.1 [Never Use && with Potentially Falsy Values](#11-never-use--with-potentially-falsy-values)
|
|
25
|
+
- 1.2 [Wrap Strings in Text Components](#12-wrap-strings-in-text-components)
|
|
26
|
+
2. [List Performance](#2-list-performance) — **HIGH**
|
|
27
|
+
- 2.1 [Avoid Inline Objects in renderItem](#21-avoid-inline-objects-in-renderitem)
|
|
28
|
+
- 2.2 [Hoist callbacks to the root of lists](#22-hoist-callbacks-to-the-root-of-lists)
|
|
29
|
+
- 2.3 [Keep List Items Lightweight](#23-keep-list-items-lightweight)
|
|
30
|
+
- 2.4 [Optimize List Performance with Stable Object References](#24-optimize-list-performance-with-stable-object-references)
|
|
31
|
+
- 2.5 [Pass Primitives to List Items for Memoization](#25-pass-primitives-to-list-items-for-memoization)
|
|
32
|
+
- 2.6 [Use a List Virtualizer for Any List](#26-use-a-list-virtualizer-for-any-list)
|
|
33
|
+
- 2.7 [Use Compressed Images in Lists](#27-use-compressed-images-in-lists)
|
|
34
|
+
- 2.8 [Use Item Types for Heterogeneous Lists](#28-use-item-types-for-heterogeneous-lists)
|
|
35
|
+
3. [Animation](#3-animation) — **HIGH**
|
|
36
|
+
- 3.1 [Animate Transform and Opacity Instead of Layout Properties](#31-animate-transform-and-opacity-instead-of-layout-properties)
|
|
37
|
+
- 3.2 [Prefer useDerivedValue Over useAnimatedReaction](#32-prefer-usederivedvalue-over-useanimatedreaction)
|
|
38
|
+
- 3.3 [Use GestureDetector for Animated Press States](#33-use-gesturedetector-for-animated-press-states)
|
|
39
|
+
4. [Scroll Performance](#4-scroll-performance) — **HIGH**
|
|
40
|
+
- 4.1 [Never Track Scroll Position in useState](#41-never-track-scroll-position-in-usestate)
|
|
41
|
+
5. [Navigation](#5-navigation) — **HIGH**
|
|
42
|
+
- 5.1 [Use Native Navigators for Navigation](#51-use-native-navigators-for-navigation)
|
|
43
|
+
6. [React State](#6-react-state) — **MEDIUM**
|
|
44
|
+
- 6.1 [Minimize State Variables and Derive Values](#61-minimize-state-variables-and-derive-values)
|
|
45
|
+
- 6.2 [Use fallback state instead of initialState](#62-use-fallback-state-instead-of-initialstate)
|
|
46
|
+
- 6.3 [useState Dispatch updaters for State That Depends on Current Value](#63-usestate-dispatch-updaters-for-state-that-depends-on-current-value)
|
|
47
|
+
7. [State Architecture](#7-state-architecture) — **MEDIUM**
|
|
48
|
+
- 7.1 [State Must Represent Ground Truth](#71-state-must-represent-ground-truth)
|
|
49
|
+
8. [React Compiler](#8-react-compiler) — **MEDIUM**
|
|
50
|
+
- 8.1 [Destructure Functions Early in Render (React Compiler)](#81-destructure-functions-early-in-render-react-compiler)
|
|
51
|
+
- 8.2 [Use .get() and .set() for Reanimated Shared Values (not .value)](#82-use-get-and-set-for-reanimated-shared-values-not-value)
|
|
52
|
+
9. [User Interface](#9-user-interface) — **MEDIUM**
|
|
53
|
+
- 9.1 [Measuring View Dimensions](#91-measuring-view-dimensions)
|
|
54
|
+
- 9.2 [Modern React Native Styling Patterns](#92-modern-react-native-styling-patterns)
|
|
55
|
+
- 9.3 [Use contentInset for Dynamic ScrollView Spacing](#93-use-contentinset-for-dynamic-scrollview-spacing)
|
|
56
|
+
- 9.4 [Use contentInsetAdjustmentBehavior for Safe Areas](#94-use-contentinsetadjustmentbehavior-for-safe-areas)
|
|
57
|
+
- 9.5 [Use expo-image for Optimized Images](#95-use-expo-image-for-optimized-images)
|
|
58
|
+
- 9.6 [Use Galeria for Image Galleries and Lightbox](#96-use-galeria-for-image-galleries-and-lightbox)
|
|
59
|
+
- 9.7 [Use Native Menus for Dropdowns and Context Menus](#97-use-native-menus-for-dropdowns-and-context-menus)
|
|
60
|
+
- 9.8 [Use Native Modals Over JS-Based Bottom Sheets](#98-use-native-modals-over-js-based-bottom-sheets)
|
|
61
|
+
- 9.9 [Use Pressable Instead of Touchable Components](#99-use-pressable-instead-of-touchable-components)
|
|
62
|
+
10. [Design System](#10-design-system) — **MEDIUM**
|
|
63
|
+
- 10.1 [Use Compound Components Over Polymorphic Children](#101-use-compound-components-over-polymorphic-children)
|
|
64
|
+
11. [Monorepo](#11-monorepo) — **LOW**
|
|
65
|
+
- 11.1 [Install Native Dependencies in App Directory](#111-install-native-dependencies-in-app-directory)
|
|
66
|
+
- 11.2 [Use Single Dependency Versions Across Monorepo](#112-use-single-dependency-versions-across-monorepo)
|
|
67
|
+
12. [Third-Party Dependencies](#12-third-party-dependencies) — **LOW**
|
|
68
|
+
- 12.1 [Import from Design System Folder](#121-import-from-design-system-folder)
|
|
69
|
+
13. [JavaScript](#13-javascript) — **LOW**
|
|
70
|
+
- 13.1 [Hoist Intl Formatter Creation](#131-hoist-intl-formatter-creation)
|
|
71
|
+
14. [Fonts](#14-fonts) — **LOW**
|
|
72
|
+
- 14.1 [Load fonts natively at build time](#141-load-fonts-natively-at-build-time)
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 1. Core Rendering
|
|
77
|
+
|
|
78
|
+
**Impact: CRITICAL**
|
|
79
|
+
|
|
80
|
+
Fundamental React Native rendering rules. Violations cause
|
|
81
|
+
runtime crashes or broken UI.
|
|
82
|
+
|
|
83
|
+
### 1.1 Never Use && with Potentially Falsy Values
|
|
84
|
+
|
|
85
|
+
**Impact: CRITICAL (prevents production crash)**
|
|
86
|
+
|
|
87
|
+
Never use `{value && <Component />}` when `value` could be an empty string or
|
|
88
|
+
|
|
89
|
+
`0`. These are falsy but JSX-renderable—React Native will try to render them as
|
|
90
|
+
|
|
91
|
+
text outside a `<Text>` component, causing a hard crash in production.
|
|
92
|
+
|
|
93
|
+
**Incorrect: crashes if count is 0 or name is ""**
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
function Profile({ name, count }: { name: string; count: number }) {
|
|
97
|
+
return (
|
|
98
|
+
<View>
|
|
99
|
+
{name && <Text>{name}</Text>}
|
|
100
|
+
{count && <Text>{count} items</Text>}
|
|
101
|
+
</View>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
// If name="" or count=0, renders the falsy value → crash
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Correct: ternary with null**
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
function Profile({ name, count }: { name: string; count: number }) {
|
|
111
|
+
return (
|
|
112
|
+
<View>
|
|
113
|
+
{name ? <Text>{name}</Text> : null}
|
|
114
|
+
{count ? <Text>{count} items</Text> : null}
|
|
115
|
+
</View>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Correct: explicit boolean coercion**
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
function Profile({ name, count }: { name: string; count: number }) {
|
|
124
|
+
return (
|
|
125
|
+
<View>
|
|
126
|
+
{!!name && <Text>{name}</Text>}
|
|
127
|
+
{!!count && <Text>{count} items</Text>}
|
|
128
|
+
</View>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Best: early return**
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
function Profile({ name, count }: { name: string; count: number }) {
|
|
137
|
+
if (!name) return null
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<View>
|
|
141
|
+
<Text>{name}</Text>
|
|
142
|
+
{count > 0 ? <Text>{count} items</Text> : null}
|
|
143
|
+
</View>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Early returns are clearest. When using conditionals inline, prefer ternary or
|
|
149
|
+
|
|
150
|
+
explicit boolean checks.
|
|
151
|
+
|
|
152
|
+
**Lint rule:** Enable `react/jsx-no-leaked-render` from
|
|
153
|
+
|
|
154
|
+
[eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-leaked-render.md)
|
|
155
|
+
|
|
156
|
+
to catch this automatically.
|
|
157
|
+
|
|
158
|
+
### 1.2 Wrap Strings in Text Components
|
|
159
|
+
|
|
160
|
+
**Impact: CRITICAL (prevents runtime crash)**
|
|
161
|
+
|
|
162
|
+
Strings must be rendered inside `<Text>`. React Native crashes if a string is a
|
|
163
|
+
|
|
164
|
+
direct child of `<View>`.
|
|
165
|
+
|
|
166
|
+
**Incorrect: crashes**
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
import { View } from 'react-native'
|
|
170
|
+
|
|
171
|
+
function Greeting({ name }: { name: string }) {
|
|
172
|
+
return <View>Hello, {name}!</View>
|
|
173
|
+
}
|
|
174
|
+
// Error: Text strings must be rendered within a <Text> component.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Correct:**
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
import { View, Text } from 'react-native'
|
|
181
|
+
|
|
182
|
+
function Greeting({ name }: { name: string }) {
|
|
183
|
+
return (
|
|
184
|
+
<View>
|
|
185
|
+
<Text>Hello, {name}!</Text>
|
|
186
|
+
</View>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 2. List Performance
|
|
194
|
+
|
|
195
|
+
**Impact: HIGH**
|
|
196
|
+
|
|
197
|
+
Optimizing virtualized lists (FlatList, LegendList, FlashList)
|
|
198
|
+
for smooth scrolling and fast updates.
|
|
199
|
+
|
|
200
|
+
### 2.1 Avoid Inline Objects in renderItem
|
|
201
|
+
|
|
202
|
+
**Impact: HIGH (prevents unnecessary re-renders of memoized list items)**
|
|
203
|
+
|
|
204
|
+
Don't create new objects inside `renderItem` to pass as props. Inline objects
|
|
205
|
+
|
|
206
|
+
create new references on every render, breaking memoization. Pass primitive
|
|
207
|
+
|
|
208
|
+
values directly from `item` instead.
|
|
209
|
+
|
|
210
|
+
**Incorrect: inline object breaks memoization**
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
function UserList({ users }: { users: User[] }) {
|
|
214
|
+
return (
|
|
215
|
+
<LegendList
|
|
216
|
+
data={users}
|
|
217
|
+
renderItem={({ item }) => (
|
|
218
|
+
<UserRow
|
|
219
|
+
// Bad: new object on every render
|
|
220
|
+
user={{ id: item.id, name: item.name, avatar: item.avatar }}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
/>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Incorrect: inline style object**
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
renderItem={({ item }) => (
|
|
232
|
+
<UserRow
|
|
233
|
+
name={item.name}
|
|
234
|
+
// Bad: new style object on every render
|
|
235
|
+
style={{ backgroundColor: item.isActive ? 'green' : 'gray' }}
|
|
236
|
+
/>
|
|
237
|
+
)}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Correct: pass item directly or primitives**
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
function UserList({ users }: { users: User[] }) {
|
|
244
|
+
return (
|
|
245
|
+
<LegendList
|
|
246
|
+
data={users}
|
|
247
|
+
renderItem={({ item }) => (
|
|
248
|
+
// Good: pass the item directly
|
|
249
|
+
<UserRow user={item} />
|
|
250
|
+
)}
|
|
251
|
+
/>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Correct: pass primitives, derive inside child**
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
renderItem={({ item }) => (
|
|
260
|
+
<UserRow
|
|
261
|
+
id={item.id}
|
|
262
|
+
name={item.name}
|
|
263
|
+
isActive={item.isActive}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
const UserRow = memo(function UserRow({ id, name, isActive }: Props) {
|
|
268
|
+
// Good: derive style inside memoized component
|
|
269
|
+
const backgroundColor = isActive ? 'green' : 'gray'
|
|
270
|
+
return <View style={[styles.row, { backgroundColor }]}>{/* ... */}</View>
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Correct: hoist static styles in module scope**
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
const activeStyle = { backgroundColor: 'green' }
|
|
278
|
+
const inactiveStyle = { backgroundColor: 'gray' }
|
|
279
|
+
|
|
280
|
+
renderItem={({ item }) => (
|
|
281
|
+
<UserRow
|
|
282
|
+
name={item.name}
|
|
283
|
+
// Good: stable references
|
|
284
|
+
style={item.isActive ? activeStyle : inactiveStyle}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Passing primitives or stable references allows `memo()` to skip re-renders when
|
|
290
|
+
|
|
291
|
+
the actual values haven't changed.
|
|
292
|
+
|
|
293
|
+
**Note:** If you have the React Compiler enabled, it handles memoization
|
|
294
|
+
|
|
295
|
+
automatically and these manual optimizations become less critical.
|
|
296
|
+
|
|
297
|
+
### 2.2 Hoist callbacks to the root of lists
|
|
298
|
+
|
|
299
|
+
**Impact: MEDIUM (Fewer re-renders and faster lists)**
|
|
300
|
+
|
|
301
|
+
When passing callback functions to list items, create a single instance of the
|
|
302
|
+
|
|
303
|
+
callback at the root of the list. Items should then call it with a unique
|
|
304
|
+
|
|
305
|
+
identifier.
|
|
306
|
+
|
|
307
|
+
**Incorrect: creates a new callback on each render**
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
return (
|
|
311
|
+
<LegendList
|
|
312
|
+
renderItem={({ item }) => {
|
|
313
|
+
// bad: creates a new callback on each render
|
|
314
|
+
const onPress = () => handlePress(item.id)
|
|
315
|
+
return <Item key={item.id} item={item} onPress={onPress} />
|
|
316
|
+
}}
|
|
317
|
+
/>
|
|
318
|
+
)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Correct: a single function instance passed to each item**
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id])
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<LegendList
|
|
328
|
+
renderItem={({ item }) => (
|
|
329
|
+
<Item key={item.id} item={item} onPress={onPress} />
|
|
330
|
+
)}
|
|
331
|
+
/>
|
|
332
|
+
)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Reference: [https://example.com](https://example.com)
|
|
336
|
+
|
|
337
|
+
### 2.3 Keep List Items Lightweight
|
|
338
|
+
|
|
339
|
+
**Impact: HIGH (reduces render time for visible items during scroll)**
|
|
340
|
+
|
|
341
|
+
List items should be as inexpensive as possible to render. Minimize hooks, avoid
|
|
342
|
+
|
|
343
|
+
queries, and limit React Context access. Virtualized lists render many items
|
|
344
|
+
|
|
345
|
+
during scroll—expensive items cause jank.
|
|
346
|
+
|
|
347
|
+
**Incorrect: heavy list item**
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
function ProductRow({ id }: { id: string }) {
|
|
351
|
+
// Bad: query inside list item
|
|
352
|
+
const { data: product } = useQuery(['product', id], () => fetchProduct(id))
|
|
353
|
+
// Bad: multiple context accesses
|
|
354
|
+
const theme = useContext(ThemeContext)
|
|
355
|
+
const user = useContext(UserContext)
|
|
356
|
+
const cart = useContext(CartContext)
|
|
357
|
+
// Bad: expensive computation
|
|
358
|
+
const recommendations = useMemo(
|
|
359
|
+
() => computeRecommendations(product),
|
|
360
|
+
[product]
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return <View>{/* ... */}</View>
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Correct: lightweight list item**
|
|
368
|
+
|
|
369
|
+
```tsx
|
|
370
|
+
function ProductRow({ name, price, imageUrl }: Props) {
|
|
371
|
+
// Good: receives only primitives, minimal hooks
|
|
372
|
+
return (
|
|
373
|
+
<View>
|
|
374
|
+
<Image source={{ uri: imageUrl }} />
|
|
375
|
+
<Text>{name}</Text>
|
|
376
|
+
<Text>{price}</Text>
|
|
377
|
+
</View>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**Move data fetching to parent:**
|
|
383
|
+
|
|
384
|
+
```tsx
|
|
385
|
+
// Parent fetches all data once
|
|
386
|
+
function ProductList() {
|
|
387
|
+
const { data: products } = useQuery(['products'], fetchProducts)
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
<LegendList
|
|
391
|
+
data={products}
|
|
392
|
+
renderItem={({ item }) => (
|
|
393
|
+
<ProductRow name={item.name} price={item.price} imageUrl={item.image} />
|
|
394
|
+
)}
|
|
395
|
+
/>
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**For shared values, use Zustand selectors instead of Context:**
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
// Incorrect: Context causes re-render when any cart value changes
|
|
404
|
+
function ProductRow({ id, name }: Props) {
|
|
405
|
+
const { items } = useContext(CartContext)
|
|
406
|
+
const inCart = items.includes(id)
|
|
407
|
+
// ...
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Correct: Zustand selector only re-renders when this specific value changes
|
|
411
|
+
function ProductRow({ id, name }: Props) {
|
|
412
|
+
// use Set.has (created once at the root) instead of Array.includes()
|
|
413
|
+
const inCart = useCartStore((s) => s.items.has(id))
|
|
414
|
+
// ...
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Guidelines for list items:**
|
|
419
|
+
|
|
420
|
+
- No queries or data fetching
|
|
421
|
+
|
|
422
|
+
- No expensive computations (move to parent or memoize at parent level)
|
|
423
|
+
|
|
424
|
+
- Prefer Zustand selectors over React Context
|
|
425
|
+
|
|
426
|
+
- Minimize useState/useEffect hooks
|
|
427
|
+
|
|
428
|
+
- Pass pre-computed values as props
|
|
429
|
+
|
|
430
|
+
The goal: list items should be simple rendering functions that take props and
|
|
431
|
+
|
|
432
|
+
return JSX.
|
|
433
|
+
|
|
434
|
+
### 2.4 Optimize List Performance with Stable Object References
|
|
435
|
+
|
|
436
|
+
**Impact: CRITICAL (virtualization relies on reference stability)**
|
|
437
|
+
|
|
438
|
+
Don't map or filter data before passing to virtualized lists. Virtualization
|
|
439
|
+
|
|
440
|
+
relies on object reference stability to know what changed—new references cause
|
|
441
|
+
|
|
442
|
+
full re-renders of all visible items. Attempt to prevent frequent renders at the
|
|
443
|
+
|
|
444
|
+
list-parent level.
|
|
445
|
+
|
|
446
|
+
Where needed, use context selectors within list items.
|
|
447
|
+
|
|
448
|
+
**Incorrect: creates new object references on every keystroke**
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
function DomainSearch() {
|
|
452
|
+
const { keyword, setKeyword } = useKeywordZustandState()
|
|
453
|
+
const { data: tlds } = useTlds()
|
|
454
|
+
|
|
455
|
+
// Bad: creates new objects on every render, reparenting the entire list on every keystroke
|
|
456
|
+
const domains = tlds.map((tld) => ({
|
|
457
|
+
domain: `${keyword}.${tld.name}`,
|
|
458
|
+
tld: tld.name,
|
|
459
|
+
price: tld.price,
|
|
460
|
+
}))
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<>
|
|
464
|
+
<TextInput value={keyword} onChangeText={setKeyword} />
|
|
465
|
+
<LegendList
|
|
466
|
+
data={domains}
|
|
467
|
+
renderItem={({ item }) => <DomainItem item={item} keyword={keyword} />}
|
|
468
|
+
/>
|
|
469
|
+
</>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Correct: stable references, transform inside items**
|
|
475
|
+
|
|
476
|
+
```tsx
|
|
477
|
+
const renderItem = ({ item }) => <DomainItem tld={item} />
|
|
478
|
+
|
|
479
|
+
function DomainSearch() {
|
|
480
|
+
const { data: tlds } = useTlds()
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<LegendList
|
|
484
|
+
// good: as long as the data is stable, LegendList will not re-render the entire list
|
|
485
|
+
data={tlds}
|
|
486
|
+
renderItem={renderItem}
|
|
487
|
+
/>
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function DomainItem({ tld }: { tld: Tld }) {
|
|
492
|
+
// good: transform within items, and don't pass the dynamic data as a prop
|
|
493
|
+
// good: use a selector function from zustand to receive a stable string back
|
|
494
|
+
const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name)
|
|
495
|
+
return <Text>{domain}</Text>
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**Updating parent array reference:**
|
|
500
|
+
|
|
501
|
+
```tsx
|
|
502
|
+
// good: creates a new array instance without mutating the inner objects
|
|
503
|
+
// good: parent array reference is unaffected by typing and updating "keyword"
|
|
504
|
+
const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
505
|
+
|
|
506
|
+
return <LegendList data={sortedTlds} renderItem={renderItem} />
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Creating a new array instance can be okay, as long as its inner object
|
|
510
|
+
|
|
511
|
+
references are stable. For instance, if you sort a list of objects:
|
|
512
|
+
|
|
513
|
+
Even though this creates a new array instance `sortedTlds`, the inner object
|
|
514
|
+
|
|
515
|
+
references are stable.
|
|
516
|
+
|
|
517
|
+
**With zustand for dynamic data: avoids parent re-renders**
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
function DomainItemFavoriteButton({ tld }: { tld: Tld }) {
|
|
521
|
+
const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id))
|
|
522
|
+
return <TldFavoriteButton isFavorited={isFavorited} />
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Virtualization can now skip items that haven't changed when typing. Only visible
|
|
527
|
+
|
|
528
|
+
items (~20) re-render on keystroke, rather than the parent.
|
|
529
|
+
|
|
530
|
+
**Deriving state within list items based on parent data (avoids parent
|
|
531
|
+
|
|
532
|
+
re-renders):**
|
|
533
|
+
|
|
534
|
+
For components where the data is conditional based on the parent state, this
|
|
535
|
+
|
|
536
|
+
pattern is even more important. For example, if you are checking if an item is
|
|
537
|
+
|
|
538
|
+
favorited, toggling favorites only re-renders one component if the item itself
|
|
539
|
+
|
|
540
|
+
is in charge of accessing the state rather than the parent:
|
|
541
|
+
|
|
542
|
+
Note: if you're using the React Compiler, you can read React Context values
|
|
543
|
+
|
|
544
|
+
directly within list items. Although this is slightly slower than using a
|
|
545
|
+
|
|
546
|
+
Zustand selector in most cases, the effect may be negligible.
|
|
547
|
+
|
|
548
|
+
### 2.5 Pass Primitives to List Items for Memoization
|
|
549
|
+
|
|
550
|
+
**Impact: HIGH (enables effective memo() comparison)**
|
|
551
|
+
|
|
552
|
+
When possible, pass only primitive values (strings, numbers, booleans) as props
|
|
553
|
+
|
|
554
|
+
to list item components. Primitives enable shallow comparison in `memo()` to
|
|
555
|
+
|
|
556
|
+
work correctly, skipping re-renders when values haven't changed.
|
|
557
|
+
|
|
558
|
+
**Incorrect: object prop requires deep comparison**
|
|
559
|
+
|
|
560
|
+
```tsx
|
|
561
|
+
type User = { id: string; name: string; email: string; avatar: string }
|
|
562
|
+
|
|
563
|
+
const UserRow = memo(function UserRow({ user }: { user: User }) {
|
|
564
|
+
// memo() compares user by reference, not value
|
|
565
|
+
// If parent creates new user object, this re-renders even if data is same
|
|
566
|
+
return <Text>{user.name}</Text>
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
renderItem={({ item }) => <UserRow user={item} />}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
This can still be optimized, but it is harder to memoize properly.
|
|
573
|
+
|
|
574
|
+
**Correct: primitive props enable shallow comparison**
|
|
575
|
+
|
|
576
|
+
```tsx
|
|
577
|
+
const UserRow = memo(function UserRow({
|
|
578
|
+
id,
|
|
579
|
+
name,
|
|
580
|
+
email,
|
|
581
|
+
}: {
|
|
582
|
+
id: string
|
|
583
|
+
name: string
|
|
584
|
+
email: string
|
|
585
|
+
}) {
|
|
586
|
+
// memo() compares each primitive directly
|
|
587
|
+
// Re-renders only if id, name, or email actually changed
|
|
588
|
+
return <Text>{name}</Text>
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
renderItem={({ item }) => (
|
|
592
|
+
<UserRow id={item.id} name={item.name} email={item.email} />
|
|
593
|
+
)}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Pass only what you need:**
|
|
597
|
+
|
|
598
|
+
```tsx
|
|
599
|
+
// Incorrect: passing entire item when you only need name
|
|
600
|
+
<UserRow user={item} />
|
|
601
|
+
|
|
602
|
+
// Correct: pass only the fields the component uses
|
|
603
|
+
<UserRow name={item.name} avatarUrl={item.avatar} />
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
**For callbacks, hoist or use item ID:**
|
|
607
|
+
|
|
608
|
+
```tsx
|
|
609
|
+
// Incorrect: inline function creates new reference
|
|
610
|
+
<UserRow name={item.name} onPress={() => handlePress(item.id)} />
|
|
611
|
+
|
|
612
|
+
// Correct: pass ID, handle in child
|
|
613
|
+
<UserRow id={item.id} name={item.name} />
|
|
614
|
+
|
|
615
|
+
const UserRow = memo(function UserRow({ id, name }: Props) {
|
|
616
|
+
const handlePress = useCallback(() => {
|
|
617
|
+
// use id here
|
|
618
|
+
}, [id])
|
|
619
|
+
return <Pressable onPress={handlePress}><Text>{name}</Text></Pressable>
|
|
620
|
+
})
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Primitive props make memoization predictable and effective.
|
|
624
|
+
|
|
625
|
+
**Note:** If you have the React Compiler enabled, you do not need to use
|
|
626
|
+
|
|
627
|
+
`memo()` or `useCallback()`, but the object references still apply.
|
|
628
|
+
|
|
629
|
+
### 2.6 Use a List Virtualizer for Any List
|
|
630
|
+
|
|
631
|
+
**Impact: HIGH (reduced memory, faster mounts)**
|
|
632
|
+
|
|
633
|
+
Use a list virtualizer like LegendList or FlashList instead of ScrollView with
|
|
634
|
+
|
|
635
|
+
mapped children—even for short lists. Virtualizers only render visible items,
|
|
636
|
+
|
|
637
|
+
reducing memory usage and mount time. ScrollView renders all children upfront,
|
|
638
|
+
|
|
639
|
+
which gets expensive quickly.
|
|
640
|
+
|
|
641
|
+
**Incorrect: ScrollView renders all items at once**
|
|
642
|
+
|
|
643
|
+
```tsx
|
|
644
|
+
function Feed({ items }: { items: Item[] }) {
|
|
645
|
+
return (
|
|
646
|
+
<ScrollView>
|
|
647
|
+
{items.map((item) => (
|
|
648
|
+
<ItemCard key={item.id} item={item} />
|
|
649
|
+
))}
|
|
650
|
+
</ScrollView>
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
// 50 items = 50 components mounted, even if only 10 visible
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Correct: virtualizer renders only visible items**
|
|
657
|
+
|
|
658
|
+
```tsx
|
|
659
|
+
import { LegendList } from '@legendapp/list'
|
|
660
|
+
|
|
661
|
+
function Feed({ items }: { items: Item[] }) {
|
|
662
|
+
return (
|
|
663
|
+
<LegendList
|
|
664
|
+
data={items}
|
|
665
|
+
// if you aren't using React Compiler, wrap these with useCallback
|
|
666
|
+
renderItem={({ item }) => <ItemCard item={item} />}
|
|
667
|
+
keyExtractor={(item) => item.id}
|
|
668
|
+
estimatedItemSize={80}
|
|
669
|
+
/>
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
// Only ~10-15 visible items mounted at a time
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
**Alternative: FlashList**
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
import { FlashList } from '@shopify/flash-list'
|
|
679
|
+
|
|
680
|
+
function Feed({ items }: { items: Item[] }) {
|
|
681
|
+
return (
|
|
682
|
+
<FlashList
|
|
683
|
+
data={items}
|
|
684
|
+
// if you aren't using React Compiler, wrap these with useCallback
|
|
685
|
+
renderItem={({ item }) => <ItemCard item={item} />}
|
|
686
|
+
keyExtractor={(item) => item.id}
|
|
687
|
+
/>
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Benefits apply to any screen with scrollable content—profiles, settings, feeds,
|
|
693
|
+
|
|
694
|
+
search results. Default to virtualization.
|
|
695
|
+
|
|
696
|
+
### 2.7 Use Compressed Images in Lists
|
|
697
|
+
|
|
698
|
+
**Impact: HIGH (faster load times, less memory)**
|
|
699
|
+
|
|
700
|
+
Always load compressed, appropriately-sized images in lists. Full-resolution
|
|
701
|
+
|
|
702
|
+
images consume excessive memory and cause scroll jank. Request thumbnails from
|
|
703
|
+
|
|
704
|
+
your server or use an image CDN with resize parameters.
|
|
705
|
+
|
|
706
|
+
**Incorrect: full-resolution images**
|
|
707
|
+
|
|
708
|
+
```tsx
|
|
709
|
+
function ProductItem({ product }: { product: Product }) {
|
|
710
|
+
return (
|
|
711
|
+
<View>
|
|
712
|
+
{/* 4000x3000 image loaded for a 100x100 thumbnail */}
|
|
713
|
+
<Image
|
|
714
|
+
source={{ uri: product.imageUrl }}
|
|
715
|
+
style={{ width: 100, height: 100 }}
|
|
716
|
+
/>
|
|
717
|
+
<Text>{product.name}</Text>
|
|
718
|
+
</View>
|
|
719
|
+
)
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
**Correct: request appropriately-sized image**
|
|
724
|
+
|
|
725
|
+
```tsx
|
|
726
|
+
function ProductItem({ product }: { product: Product }) {
|
|
727
|
+
// Request a 200x200 image (2x for retina)
|
|
728
|
+
const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`
|
|
729
|
+
|
|
730
|
+
return (
|
|
731
|
+
<View>
|
|
732
|
+
<Image
|
|
733
|
+
source={{ uri: thumbnailUrl }}
|
|
734
|
+
style={{ width: 100, height: 100 }}
|
|
735
|
+
contentFit='cover'
|
|
736
|
+
/>
|
|
737
|
+
<Text>{product.name}</Text>
|
|
738
|
+
</View>
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
Use an optimized image component with built-in caching and placeholder support,
|
|
744
|
+
|
|
745
|
+
such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood).
|
|
746
|
+
|
|
747
|
+
Request images at 2x the display size for retina screens.
|
|
748
|
+
|
|
749
|
+
### 2.8 Use Item Types for Heterogeneous Lists
|
|
750
|
+
|
|
751
|
+
**Impact: HIGH (efficient recycling, less layout thrashing)**
|
|
752
|
+
|
|
753
|
+
When a list has different item layouts (messages, images, headers, etc.), use a
|
|
754
|
+
|
|
755
|
+
`type` field on each item and provide `getItemType` to the list. This puts items
|
|
756
|
+
|
|
757
|
+
into separate recycling pools so a message component never gets recycled into an
|
|
758
|
+
|
|
759
|
+
image component.
|
|
760
|
+
|
|
761
|
+
[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2)
|
|
762
|
+
|
|
763
|
+
**Incorrect: single component with conditionals**
|
|
764
|
+
|
|
765
|
+
```tsx
|
|
766
|
+
type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean }
|
|
767
|
+
|
|
768
|
+
function ListItem({ item }: { item: Item }) {
|
|
769
|
+
if (item.isHeader) {
|
|
770
|
+
return <HeaderItem title={item.text} />
|
|
771
|
+
}
|
|
772
|
+
if (item.imageUrl) {
|
|
773
|
+
return <ImageItem url={item.imageUrl} />
|
|
774
|
+
}
|
|
775
|
+
return <MessageItem text={item.text} />
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function Feed({ items }: { items: Item[] }) {
|
|
779
|
+
return (
|
|
780
|
+
<LegendList
|
|
781
|
+
data={items}
|
|
782
|
+
renderItem={({ item }) => <ListItem item={item} />}
|
|
783
|
+
recycleItems
|
|
784
|
+
/>
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
**Correct: typed items with separate components**
|
|
790
|
+
|
|
791
|
+
```tsx
|
|
792
|
+
type HeaderItem = { id: string; type: 'header'; title: string }
|
|
793
|
+
type MessageItem = { id: string; type: 'message'; text: string }
|
|
794
|
+
type ImageItem = { id: string; type: 'image'; url: string }
|
|
795
|
+
type FeedItem = HeaderItem | MessageItem | ImageItem
|
|
796
|
+
|
|
797
|
+
function Feed({ items }: { items: FeedItem[] }) {
|
|
798
|
+
return (
|
|
799
|
+
<LegendList
|
|
800
|
+
data={items}
|
|
801
|
+
keyExtractor={(item) => item.id}
|
|
802
|
+
getItemType={(item) => item.type}
|
|
803
|
+
renderItem={({ item }) => {
|
|
804
|
+
switch (item.type) {
|
|
805
|
+
case 'header':
|
|
806
|
+
return <SectionHeader title={item.title} />
|
|
807
|
+
case 'message':
|
|
808
|
+
return <MessageRow text={item.text} />
|
|
809
|
+
case 'image':
|
|
810
|
+
return <ImageRow url={item.url} />
|
|
811
|
+
}
|
|
812
|
+
}}
|
|
813
|
+
recycleItems
|
|
814
|
+
/>
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
**Why this matters:**
|
|
820
|
+
|
|
821
|
+
```tsx
|
|
822
|
+
<LegendList
|
|
823
|
+
data={items}
|
|
824
|
+
keyExtractor={(item) => item.id}
|
|
825
|
+
getItemType={(item) => item.type}
|
|
826
|
+
getEstimatedItemSize={(index, item, itemType) => {
|
|
827
|
+
switch (itemType) {
|
|
828
|
+
case 'header':
|
|
829
|
+
return 48
|
|
830
|
+
case 'message':
|
|
831
|
+
return 72
|
|
832
|
+
case 'image':
|
|
833
|
+
return 300
|
|
834
|
+
default:
|
|
835
|
+
return 72
|
|
836
|
+
}
|
|
837
|
+
}}
|
|
838
|
+
renderItem={({ item }) => {
|
|
839
|
+
/* ... */
|
|
840
|
+
}}
|
|
841
|
+
recycleItems
|
|
842
|
+
/>
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
- **Recycling efficiency**: Items with the same type share a recycling pool
|
|
846
|
+
|
|
847
|
+
- **No layout thrashing**: A header never recycles into an image cell
|
|
848
|
+
|
|
849
|
+
- **Type safety**: TypeScript can narrow the item type in each branch
|
|
850
|
+
|
|
851
|
+
- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for
|
|
852
|
+
|
|
853
|
+
accurate estimates per type
|
|
854
|
+
|
|
855
|
+
---
|
|
856
|
+
|
|
857
|
+
## 3. Animation
|
|
858
|
+
|
|
859
|
+
**Impact: HIGH**
|
|
860
|
+
|
|
861
|
+
GPU-accelerated animations, Reanimated patterns, and avoiding
|
|
862
|
+
render thrashing during gestures.
|
|
863
|
+
|
|
864
|
+
### 3.1 Animate Transform and Opacity Instead of Layout Properties
|
|
865
|
+
|
|
866
|
+
**Impact: HIGH (GPU-accelerated animations, no layout recalculation)**
|
|
867
|
+
|
|
868
|
+
Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout.
|
|
869
|
+
|
|
870
|
+
**Incorrect: animates height, triggers layout every frame**
|
|
871
|
+
|
|
872
|
+
```tsx
|
|
873
|
+
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
|
874
|
+
|
|
875
|
+
function CollapsiblePanel({ expanded }: { expanded: boolean }) {
|
|
876
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
877
|
+
height: withTiming(expanded ? 200 : 0), // triggers layout on every frame
|
|
878
|
+
overflow: 'hidden',
|
|
879
|
+
}))
|
|
880
|
+
|
|
881
|
+
return <Animated.View style={animatedStyle}>{children}</Animated.View>
|
|
882
|
+
}
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
**Correct: animates scaleY, GPU-accelerated**
|
|
886
|
+
|
|
887
|
+
```tsx
|
|
888
|
+
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
|
889
|
+
|
|
890
|
+
function CollapsiblePanel({ expanded }: { expanded: boolean }) {
|
|
891
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
892
|
+
transform: [
|
|
893
|
+
{ scaleY: withTiming(expanded ? 1 : 0) },
|
|
894
|
+
],
|
|
895
|
+
opacity: withTiming(expanded ? 1 : 0),
|
|
896
|
+
}))
|
|
897
|
+
|
|
898
|
+
return (
|
|
899
|
+
<Animated.View style={[{ height: 200, transformOrigin: 'top' }, animatedStyle]}>
|
|
900
|
+
{children}
|
|
901
|
+
</Animated.View>
|
|
902
|
+
)
|
|
903
|
+
}
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
**Correct: animates translateY for slide animations**
|
|
907
|
+
|
|
908
|
+
```tsx
|
|
909
|
+
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
|
910
|
+
|
|
911
|
+
function SlideIn({ visible }: { visible: boolean }) {
|
|
912
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
913
|
+
transform: [
|
|
914
|
+
{ translateY: withTiming(visible ? 0 : 100) },
|
|
915
|
+
],
|
|
916
|
+
opacity: withTiming(visible ? 1 : 0),
|
|
917
|
+
}))
|
|
918
|
+
|
|
919
|
+
return <Animated.View style={animatedStyle}>{children}</Animated.View>
|
|
920
|
+
}
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout.
|
|
924
|
+
|
|
925
|
+
### 3.2 Prefer useDerivedValue Over useAnimatedReaction
|
|
926
|
+
|
|
927
|
+
**Impact: MEDIUM (cleaner code, automatic dependency tracking)**
|
|
928
|
+
|
|
929
|
+
When deriving a shared value from another, use `useDerivedValue` instead of
|
|
930
|
+
|
|
931
|
+
`useAnimatedReaction`. Derived values are declarative, automatically track
|
|
932
|
+
|
|
933
|
+
dependencies, and return a value you can use directly. Animated reactions are
|
|
934
|
+
|
|
935
|
+
for side effects, not derivations.
|
|
936
|
+
|
|
937
|
+
[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue)
|
|
938
|
+
|
|
939
|
+
**Incorrect: useAnimatedReaction for derivation**
|
|
940
|
+
|
|
941
|
+
```tsx
|
|
942
|
+
import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated'
|
|
943
|
+
|
|
944
|
+
function MyComponent() {
|
|
945
|
+
const progress = useSharedValue(0)
|
|
946
|
+
const opacity = useSharedValue(1)
|
|
947
|
+
|
|
948
|
+
useAnimatedReaction(
|
|
949
|
+
() => progress.value,
|
|
950
|
+
(current) => {
|
|
951
|
+
opacity.value = 1 - current
|
|
952
|
+
}
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
// ...
|
|
956
|
+
}
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
**Correct: useDerivedValue**
|
|
960
|
+
|
|
961
|
+
```tsx
|
|
962
|
+
import { useSharedValue, useDerivedValue } from 'react-native-reanimated'
|
|
963
|
+
|
|
964
|
+
function MyComponent() {
|
|
965
|
+
const progress = useSharedValue(0)
|
|
966
|
+
|
|
967
|
+
const opacity = useDerivedValue(() => 1 - progress.get())
|
|
968
|
+
|
|
969
|
+
// ...
|
|
970
|
+
}
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
Use `useAnimatedReaction` only for side effects that don't produce a value
|
|
974
|
+
|
|
975
|
+
(e.g., triggering haptics, logging, calling `runOnJS`).
|
|
976
|
+
|
|
977
|
+
### 3.3 Use GestureDetector for Animated Press States
|
|
978
|
+
|
|
979
|
+
**Impact: MEDIUM (UI thread animations, smoother press feedback)**
|
|
980
|
+
|
|
981
|
+
For animated press states (scale, opacity on press), use `GestureDetector` with
|
|
982
|
+
|
|
983
|
+
`Gesture.Tap()` and shared values instead of Pressable's
|
|
984
|
+
|
|
985
|
+
`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no
|
|
986
|
+
|
|
987
|
+
JS thread round-trip for press animations.
|
|
988
|
+
|
|
989
|
+
[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture)
|
|
990
|
+
|
|
991
|
+
**Incorrect: Pressable with JS thread callbacks**
|
|
992
|
+
|
|
993
|
+
```tsx
|
|
994
|
+
import { Pressable } from 'react-native'
|
|
995
|
+
import Animated, {
|
|
996
|
+
useSharedValue,
|
|
997
|
+
useAnimatedStyle,
|
|
998
|
+
withTiming,
|
|
999
|
+
} from 'react-native-reanimated'
|
|
1000
|
+
|
|
1001
|
+
function AnimatedButton({ onPress }: { onPress: () => void }) {
|
|
1002
|
+
const scale = useSharedValue(1)
|
|
1003
|
+
|
|
1004
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
1005
|
+
transform: [{ scale: scale.value }],
|
|
1006
|
+
}))
|
|
1007
|
+
|
|
1008
|
+
return (
|
|
1009
|
+
<Pressable
|
|
1010
|
+
onPress={onPress}
|
|
1011
|
+
onPressIn={() => (scale.value = withTiming(0.95))}
|
|
1012
|
+
onPressOut={() => (scale.value = withTiming(1))}
|
|
1013
|
+
>
|
|
1014
|
+
<Animated.View style={animatedStyle}>
|
|
1015
|
+
<Text>Press me</Text>
|
|
1016
|
+
</Animated.View>
|
|
1017
|
+
</Pressable>
|
|
1018
|
+
)
|
|
1019
|
+
}
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
**Correct: GestureDetector with UI thread worklets**
|
|
1023
|
+
|
|
1024
|
+
```tsx
|
|
1025
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
1026
|
+
import Animated, {
|
|
1027
|
+
useSharedValue,
|
|
1028
|
+
useAnimatedStyle,
|
|
1029
|
+
withTiming,
|
|
1030
|
+
interpolate,
|
|
1031
|
+
runOnJS,
|
|
1032
|
+
} from 'react-native-reanimated'
|
|
1033
|
+
|
|
1034
|
+
function AnimatedButton({ onPress }: { onPress: () => void }) {
|
|
1035
|
+
// Store the press STATE (0 = not pressed, 1 = pressed)
|
|
1036
|
+
const pressed = useSharedValue(0)
|
|
1037
|
+
|
|
1038
|
+
const tap = Gesture.Tap()
|
|
1039
|
+
.onBegin(() => {
|
|
1040
|
+
pressed.set(withTiming(1))
|
|
1041
|
+
})
|
|
1042
|
+
.onFinalize(() => {
|
|
1043
|
+
pressed.set(withTiming(0))
|
|
1044
|
+
})
|
|
1045
|
+
.onEnd(() => {
|
|
1046
|
+
runOnJS(onPress)()
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
// Derive visual values from the state
|
|
1050
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
1051
|
+
transform: [
|
|
1052
|
+
{ scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) },
|
|
1053
|
+
],
|
|
1054
|
+
}))
|
|
1055
|
+
|
|
1056
|
+
return (
|
|
1057
|
+
<GestureDetector gesture={tap}>
|
|
1058
|
+
<Animated.View style={animatedStyle}>
|
|
1059
|
+
<Text>Press me</Text>
|
|
1060
|
+
</Animated.View>
|
|
1061
|
+
</GestureDetector>
|
|
1062
|
+
)
|
|
1063
|
+
}
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
Store the press **state** (0 or 1), then derive the scale via `interpolate`.
|
|
1067
|
+
|
|
1068
|
+
This keeps the shared value as ground truth. Use `runOnJS` to call JS functions
|
|
1069
|
+
|
|
1070
|
+
from worklets. Use `.set()` and `.get()` for React Compiler compatibility.
|
|
1071
|
+
|
|
1072
|
+
---
|
|
1073
|
+
|
|
1074
|
+
## 4. Scroll Performance
|
|
1075
|
+
|
|
1076
|
+
**Impact: HIGH**
|
|
1077
|
+
|
|
1078
|
+
Tracking scroll position without causing render thrashing.
|
|
1079
|
+
|
|
1080
|
+
### 4.1 Never Track Scroll Position in useState
|
|
1081
|
+
|
|
1082
|
+
**Impact: HIGH (prevents render thrashing during scroll)**
|
|
1083
|
+
|
|
1084
|
+
Never store scroll position in `useState`. Scroll events fire rapidly—state
|
|
1085
|
+
|
|
1086
|
+
updates cause render thrashing and dropped frames. Use a Reanimated shared value
|
|
1087
|
+
|
|
1088
|
+
for animations or a ref for non-reactive tracking.
|
|
1089
|
+
|
|
1090
|
+
**Incorrect: useState causes jank**
|
|
1091
|
+
|
|
1092
|
+
```tsx
|
|
1093
|
+
import { useState } from 'react'
|
|
1094
|
+
import {
|
|
1095
|
+
ScrollView,
|
|
1096
|
+
NativeSyntheticEvent,
|
|
1097
|
+
NativeScrollEvent,
|
|
1098
|
+
} from 'react-native'
|
|
1099
|
+
|
|
1100
|
+
function Feed() {
|
|
1101
|
+
const [scrollY, setScrollY] = useState(0)
|
|
1102
|
+
|
|
1103
|
+
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
1104
|
+
setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
**Correct: Reanimated for animations**
|
|
1112
|
+
|
|
1113
|
+
```tsx
|
|
1114
|
+
import Animated, {
|
|
1115
|
+
useSharedValue,
|
|
1116
|
+
useAnimatedScrollHandler,
|
|
1117
|
+
} from 'react-native-reanimated'
|
|
1118
|
+
|
|
1119
|
+
function Feed() {
|
|
1120
|
+
const scrollY = useSharedValue(0)
|
|
1121
|
+
|
|
1122
|
+
const onScroll = useAnimatedScrollHandler({
|
|
1123
|
+
onScroll: (e) => {
|
|
1124
|
+
scrollY.value = e.contentOffset.y // runs on UI thread, no re-render
|
|
1125
|
+
},
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
return (
|
|
1129
|
+
<Animated.ScrollView
|
|
1130
|
+
onScroll={onScroll}
|
|
1131
|
+
// higher number has better performance, but it fires less often.
|
|
1132
|
+
// unset this if you need higher precision over performance.
|
|
1133
|
+
scrollEventThrottle={16}
|
|
1134
|
+
/>
|
|
1135
|
+
)
|
|
1136
|
+
}
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
**Correct: ref for non-reactive tracking**
|
|
1140
|
+
|
|
1141
|
+
```tsx
|
|
1142
|
+
import { useRef } from 'react'
|
|
1143
|
+
import {
|
|
1144
|
+
ScrollView,
|
|
1145
|
+
NativeSyntheticEvent,
|
|
1146
|
+
NativeScrollEvent,
|
|
1147
|
+
} from 'react-native'
|
|
1148
|
+
|
|
1149
|
+
function Feed() {
|
|
1150
|
+
const scrollY = useRef(0)
|
|
1151
|
+
|
|
1152
|
+
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
1153
|
+
scrollY.current = e.nativeEvent.contentOffset.y // no re-render
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
|
|
1157
|
+
}
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
---
|
|
1161
|
+
|
|
1162
|
+
## 5. Navigation
|
|
1163
|
+
|
|
1164
|
+
**Impact: HIGH**
|
|
1165
|
+
|
|
1166
|
+
Using native navigators for stack and tab navigation instead of
|
|
1167
|
+
JS-based alternatives.
|
|
1168
|
+
|
|
1169
|
+
### 5.1 Use Native Navigators for Navigation
|
|
1170
|
+
|
|
1171
|
+
**Impact: HIGH (native performance, platform-appropriate UI)**
|
|
1172
|
+
|
|
1173
|
+
Always use native navigators instead of JS-based ones. Native navigators use
|
|
1174
|
+
|
|
1175
|
+
platform APIs (UINavigationController on iOS, Fragment on Android) for better
|
|
1176
|
+
|
|
1177
|
+
performance and native behavior.
|
|
1178
|
+
|
|
1179
|
+
**For stacks:** Use `@react-navigation/native-stack` or expo-router's default
|
|
1180
|
+
|
|
1181
|
+
stack (which uses native-stack). Avoid `@react-navigation/stack`.
|
|
1182
|
+
|
|
1183
|
+
**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native
|
|
1184
|
+
|
|
1185
|
+
tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters.
|
|
1186
|
+
|
|
1187
|
+
- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator)
|
|
1188
|
+
|
|
1189
|
+
- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation)
|
|
1190
|
+
|
|
1191
|
+
- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router)
|
|
1192
|
+
|
|
1193
|
+
- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs)
|
|
1194
|
+
|
|
1195
|
+
**Incorrect: JS stack navigator**
|
|
1196
|
+
|
|
1197
|
+
```tsx
|
|
1198
|
+
import { createStackNavigator } from '@react-navigation/stack'
|
|
1199
|
+
|
|
1200
|
+
const Stack = createStackNavigator()
|
|
1201
|
+
|
|
1202
|
+
function App() {
|
|
1203
|
+
return (
|
|
1204
|
+
<Stack.Navigator>
|
|
1205
|
+
<Stack.Screen name='Home' component={HomeScreen} />
|
|
1206
|
+
<Stack.Screen name='Details' component={DetailsScreen} />
|
|
1207
|
+
</Stack.Navigator>
|
|
1208
|
+
)
|
|
1209
|
+
}
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
**Correct: native stack with react-navigation**
|
|
1213
|
+
|
|
1214
|
+
```tsx
|
|
1215
|
+
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
|
1216
|
+
|
|
1217
|
+
const Stack = createNativeStackNavigator()
|
|
1218
|
+
|
|
1219
|
+
function App() {
|
|
1220
|
+
return (
|
|
1221
|
+
<Stack.Navigator>
|
|
1222
|
+
<Stack.Screen name='Home' component={HomeScreen} />
|
|
1223
|
+
<Stack.Screen name='Details' component={DetailsScreen} />
|
|
1224
|
+
</Stack.Navigator>
|
|
1225
|
+
)
|
|
1226
|
+
}
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
**Correct: expo-router uses native stack by default**
|
|
1230
|
+
|
|
1231
|
+
```tsx
|
|
1232
|
+
// app/_layout.tsx
|
|
1233
|
+
import { Stack } from 'expo-router'
|
|
1234
|
+
|
|
1235
|
+
export default function Layout() {
|
|
1236
|
+
return <Stack />
|
|
1237
|
+
}
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
**Incorrect: JS bottom tabs**
|
|
1241
|
+
|
|
1242
|
+
```tsx
|
|
1243
|
+
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
|
|
1244
|
+
|
|
1245
|
+
const Tab = createBottomTabNavigator()
|
|
1246
|
+
|
|
1247
|
+
function App() {
|
|
1248
|
+
return (
|
|
1249
|
+
<Tab.Navigator>
|
|
1250
|
+
<Tab.Screen name='Home' component={HomeScreen} />
|
|
1251
|
+
<Tab.Screen name='Settings' component={SettingsScreen} />
|
|
1252
|
+
</Tab.Navigator>
|
|
1253
|
+
)
|
|
1254
|
+
}
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
**Correct: native bottom tabs with react-navigation**
|
|
1258
|
+
|
|
1259
|
+
```tsx
|
|
1260
|
+
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'
|
|
1261
|
+
|
|
1262
|
+
const Tab = createNativeBottomTabNavigator()
|
|
1263
|
+
|
|
1264
|
+
function App() {
|
|
1265
|
+
return (
|
|
1266
|
+
<Tab.Navigator>
|
|
1267
|
+
<Tab.Screen
|
|
1268
|
+
name='Home'
|
|
1269
|
+
component={HomeScreen}
|
|
1270
|
+
options={{
|
|
1271
|
+
tabBarIcon: () => ({ sfSymbol: 'house' }),
|
|
1272
|
+
}}
|
|
1273
|
+
/>
|
|
1274
|
+
<Tab.Screen
|
|
1275
|
+
name='Settings'
|
|
1276
|
+
component={SettingsScreen}
|
|
1277
|
+
options={{
|
|
1278
|
+
tabBarIcon: () => ({ sfSymbol: 'gear' }),
|
|
1279
|
+
}}
|
|
1280
|
+
/>
|
|
1281
|
+
</Tab.Navigator>
|
|
1282
|
+
)
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
**Correct: expo-router native tabs**
|
|
1287
|
+
|
|
1288
|
+
```tsx
|
|
1289
|
+
// app/(tabs)/_layout.tsx
|
|
1290
|
+
import { NativeTabs } from 'expo-router/unstable-native-tabs'
|
|
1291
|
+
|
|
1292
|
+
export default function TabLayout() {
|
|
1293
|
+
return (
|
|
1294
|
+
<NativeTabs>
|
|
1295
|
+
<NativeTabs.Trigger name='index'>
|
|
1296
|
+
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
|
1297
|
+
<NativeTabs.Trigger.Icon sf='house.fill' md='home' />
|
|
1298
|
+
</NativeTabs.Trigger>
|
|
1299
|
+
<NativeTabs.Trigger name='settings'>
|
|
1300
|
+
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
|
1301
|
+
<NativeTabs.Trigger.Icon sf='gear' md='settings' />
|
|
1302
|
+
</NativeTabs.Trigger>
|
|
1303
|
+
</NativeTabs>
|
|
1304
|
+
)
|
|
1305
|
+
}
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the
|
|
1309
|
+
|
|
1310
|
+
first `ScrollView` at the root of each tab screen, so content scrolls correctly
|
|
1311
|
+
|
|
1312
|
+
behind the translucent tab bar. If you need to disable this, use
|
|
1313
|
+
|
|
1314
|
+
`disableAutomaticContentInsets` on the trigger.
|
|
1315
|
+
|
|
1316
|
+
**Incorrect: custom header component**
|
|
1317
|
+
|
|
1318
|
+
```tsx
|
|
1319
|
+
<Stack.Screen
|
|
1320
|
+
name='Profile'
|
|
1321
|
+
component={ProfileScreen}
|
|
1322
|
+
options={{
|
|
1323
|
+
header: () => <CustomHeader title='Profile' />,
|
|
1324
|
+
}}
|
|
1325
|
+
/>
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
**Correct: native header options**
|
|
1329
|
+
|
|
1330
|
+
```tsx
|
|
1331
|
+
<Stack.Screen
|
|
1332
|
+
name='Profile'
|
|
1333
|
+
component={ProfileScreen}
|
|
1334
|
+
options={{
|
|
1335
|
+
title: 'Profile',
|
|
1336
|
+
headerLargeTitleEnabled: true,
|
|
1337
|
+
headerSearchBarOptions: {
|
|
1338
|
+
placeholder: 'Search',
|
|
1339
|
+
},
|
|
1340
|
+
}}
|
|
1341
|
+
/>
|
|
1342
|
+
```
|
|
1343
|
+
|
|
1344
|
+
Native headers support iOS large titles, search bars, blur effects, and proper
|
|
1345
|
+
|
|
1346
|
+
safe area handling automatically.
|
|
1347
|
+
|
|
1348
|
+
- **Performance**: Native transitions and gestures run on the UI thread
|
|
1349
|
+
|
|
1350
|
+
- **Platform behavior**: Automatic iOS large titles, Android material design
|
|
1351
|
+
|
|
1352
|
+
- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe
|
|
1353
|
+
|
|
1354
|
+
areas
|
|
1355
|
+
|
|
1356
|
+
- **Accessibility**: Platform accessibility features work automatically
|
|
1357
|
+
|
|
1358
|
+
---
|
|
1359
|
+
|
|
1360
|
+
## 6. React State
|
|
1361
|
+
|
|
1362
|
+
**Impact: MEDIUM**
|
|
1363
|
+
|
|
1364
|
+
Patterns for managing React state to avoid stale closures and
|
|
1365
|
+
unnecessary re-renders.
|
|
1366
|
+
|
|
1367
|
+
### 6.1 Minimize State Variables and Derive Values
|
|
1368
|
+
|
|
1369
|
+
**Impact: MEDIUM (fewer re-renders, less state drift)**
|
|
1370
|
+
|
|
1371
|
+
Use the fewest state variables possible. If a value can be computed from existing state or props, derive it during render instead of storing it in state. Redundant state causes unnecessary re-renders and can drift out of sync.
|
|
1372
|
+
|
|
1373
|
+
**Incorrect: redundant state**
|
|
1374
|
+
|
|
1375
|
+
```tsx
|
|
1376
|
+
function Cart({ items }: { items: Item[] }) {
|
|
1377
|
+
const [total, setTotal] = useState(0)
|
|
1378
|
+
const [itemCount, setItemCount] = useState(0)
|
|
1379
|
+
|
|
1380
|
+
useEffect(() => {
|
|
1381
|
+
setTotal(items.reduce((sum, item) => sum + item.price, 0))
|
|
1382
|
+
setItemCount(items.length)
|
|
1383
|
+
}, [items])
|
|
1384
|
+
|
|
1385
|
+
return (
|
|
1386
|
+
<View>
|
|
1387
|
+
<Text>{itemCount} items</Text>
|
|
1388
|
+
<Text>Total: ${total}</Text>
|
|
1389
|
+
</View>
|
|
1390
|
+
)
|
|
1391
|
+
}
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
**Correct: derived values**
|
|
1395
|
+
|
|
1396
|
+
```tsx
|
|
1397
|
+
function Cart({ items }: { items: Item[] }) {
|
|
1398
|
+
const total = items.reduce((sum, item) => sum + item.price, 0)
|
|
1399
|
+
const itemCount = items.length
|
|
1400
|
+
|
|
1401
|
+
return (
|
|
1402
|
+
<View>
|
|
1403
|
+
<Text>{itemCount} items</Text>
|
|
1404
|
+
<Text>Total: ${total}</Text>
|
|
1405
|
+
</View>
|
|
1406
|
+
)
|
|
1407
|
+
}
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
**Another example:**
|
|
1411
|
+
|
|
1412
|
+
```tsx
|
|
1413
|
+
// Incorrect: storing both firstName, lastName, AND fullName
|
|
1414
|
+
const [firstName, setFirstName] = useState('')
|
|
1415
|
+
const [lastName, setLastName] = useState('')
|
|
1416
|
+
const [fullName, setFullName] = useState('')
|
|
1417
|
+
|
|
1418
|
+
// Correct: derive fullName
|
|
1419
|
+
const [firstName, setFirstName] = useState('')
|
|
1420
|
+
const [lastName, setLastName] = useState('')
|
|
1421
|
+
const fullName = `${firstName} ${lastName}`
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
State should be the minimal source of truth. Everything else is derived.
|
|
1425
|
+
|
|
1426
|
+
Reference: [https://react.dev/learn/choosing-the-state-structure](https://react.dev/learn/choosing-the-state-structure)
|
|
1427
|
+
|
|
1428
|
+
### 6.2 Use fallback state instead of initialState
|
|
1429
|
+
|
|
1430
|
+
**Impact: MEDIUM (reactive fallbacks without syncing)**
|
|
1431
|
+
|
|
1432
|
+
Use `undefined` as initial state and nullish coalescing (`??`) to fall back to
|
|
1433
|
+
|
|
1434
|
+
parent or server values. State represents user intent only—`undefined` means
|
|
1435
|
+
|
|
1436
|
+
"user hasn't chosen yet." This enables reactive fallbacks that update when the
|
|
1437
|
+
|
|
1438
|
+
source changes, not just on initial render.
|
|
1439
|
+
|
|
1440
|
+
**Incorrect: syncs state, loses reactivity**
|
|
1441
|
+
|
|
1442
|
+
```tsx
|
|
1443
|
+
type Props = { fallbackEnabled: boolean }
|
|
1444
|
+
|
|
1445
|
+
function Toggle({ fallbackEnabled }: Props) {
|
|
1446
|
+
const [enabled, setEnabled] = useState(defaultEnabled)
|
|
1447
|
+
// If fallbackEnabled changes, state is stale
|
|
1448
|
+
// State mixes user intent with default value
|
|
1449
|
+
|
|
1450
|
+
return <Switch value={enabled} onValueChange={setEnabled} />
|
|
1451
|
+
}
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
**Correct: state is user intent, reactive fallback**
|
|
1455
|
+
|
|
1456
|
+
```tsx
|
|
1457
|
+
type Props = { fallbackEnabled: boolean }
|
|
1458
|
+
|
|
1459
|
+
function Toggle({ fallbackEnabled }: Props) {
|
|
1460
|
+
const [_enabled, setEnabled] = useState<boolean | undefined>(undefined)
|
|
1461
|
+
const enabled = _enabled ?? defaultEnabled
|
|
1462
|
+
// undefined = user hasn't touched it, falls back to prop
|
|
1463
|
+
// If defaultEnabled changes, component reflects it
|
|
1464
|
+
// Once user interacts, their choice persists
|
|
1465
|
+
|
|
1466
|
+
return <Switch value={enabled} onValueChange={setEnabled} />
|
|
1467
|
+
}
|
|
1468
|
+
```
|
|
1469
|
+
|
|
1470
|
+
**With server data:**
|
|
1471
|
+
|
|
1472
|
+
```tsx
|
|
1473
|
+
function ProfileForm({ data }: { data: User }) {
|
|
1474
|
+
const [_theme, setTheme] = useState<string | undefined>(undefined)
|
|
1475
|
+
const theme = _theme ?? data.theme
|
|
1476
|
+
// Shows server value until user overrides
|
|
1477
|
+
// Server refetch updates the fallback automatically
|
|
1478
|
+
|
|
1479
|
+
return <ThemePicker value={theme} onChange={setTheme} />
|
|
1480
|
+
}
|
|
1481
|
+
```
|
|
1482
|
+
|
|
1483
|
+
### 6.3 useState Dispatch updaters for State That Depends on Current Value
|
|
1484
|
+
|
|
1485
|
+
**Impact: MEDIUM (avoids stale closures, prevents unnecessary re-renders)**
|
|
1486
|
+
|
|
1487
|
+
When the next state depends on the current state, use a dispatch updater
|
|
1488
|
+
|
|
1489
|
+
(`setState(prev => ...)`) instead of reading the state variable directly in a
|
|
1490
|
+
|
|
1491
|
+
callback. This avoids stale closures and ensures you're comparing against the
|
|
1492
|
+
|
|
1493
|
+
latest value.
|
|
1494
|
+
|
|
1495
|
+
**Incorrect: reads state directly**
|
|
1496
|
+
|
|
1497
|
+
```tsx
|
|
1498
|
+
const [size, setSize] = useState<Size | undefined>(undefined)
|
|
1499
|
+
|
|
1500
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
1501
|
+
const { width, height } = e.nativeEvent.layout
|
|
1502
|
+
// size may be stale in this closure
|
|
1503
|
+
if (size?.width !== width || size?.height !== height) {
|
|
1504
|
+
setSize({ width, height })
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
**Correct: dispatch updater**
|
|
1510
|
+
|
|
1511
|
+
```tsx
|
|
1512
|
+
const [size, setSize] = useState<Size | undefined>(undefined)
|
|
1513
|
+
|
|
1514
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
1515
|
+
const { width, height } = e.nativeEvent.layout
|
|
1516
|
+
setSize((prev) => {
|
|
1517
|
+
if (prev?.width === width && prev?.height === height) return prev
|
|
1518
|
+
return { width, height }
|
|
1519
|
+
})
|
|
1520
|
+
}
|
|
1521
|
+
```
|
|
1522
|
+
|
|
1523
|
+
Returning the previous value from the updater skips the re-render.
|
|
1524
|
+
|
|
1525
|
+
For primitive states, you don't need to compare values before firing a
|
|
1526
|
+
|
|
1527
|
+
re-render.
|
|
1528
|
+
|
|
1529
|
+
**Incorrect: unnecessary comparison for primitive state**
|
|
1530
|
+
|
|
1531
|
+
```tsx
|
|
1532
|
+
const [size, setSize] = useState<Size | undefined>(undefined)
|
|
1533
|
+
|
|
1534
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
1535
|
+
const { width, height } = e.nativeEvent.layout
|
|
1536
|
+
setSize((prev) => (prev === width ? prev : width))
|
|
1537
|
+
}
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1540
|
+
**Correct: sets primitive state directly**
|
|
1541
|
+
|
|
1542
|
+
```tsx
|
|
1543
|
+
const [size, setSize] = useState<Size | undefined>(undefined)
|
|
1544
|
+
|
|
1545
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
1546
|
+
const { width, height } = e.nativeEvent.layout
|
|
1547
|
+
setSize(width)
|
|
1548
|
+
}
|
|
1549
|
+
```
|
|
1550
|
+
|
|
1551
|
+
However, if the next state depends on the current state, you should still use a
|
|
1552
|
+
|
|
1553
|
+
dispatch updater.
|
|
1554
|
+
|
|
1555
|
+
**Incorrect: reads state directly from the callback**
|
|
1556
|
+
|
|
1557
|
+
```tsx
|
|
1558
|
+
const [count, setCount] = useState(0)
|
|
1559
|
+
|
|
1560
|
+
const onTap = () => {
|
|
1561
|
+
setCount(count + 1)
|
|
1562
|
+
}
|
|
1563
|
+
```
|
|
1564
|
+
|
|
1565
|
+
**Correct: dispatch updater**
|
|
1566
|
+
|
|
1567
|
+
```tsx
|
|
1568
|
+
const [count, setCount] = useState(0)
|
|
1569
|
+
|
|
1570
|
+
const onTap = () => {
|
|
1571
|
+
setCount((prev) => prev + 1)
|
|
1572
|
+
}
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
---
|
|
1576
|
+
|
|
1577
|
+
## 7. State Architecture
|
|
1578
|
+
|
|
1579
|
+
**Impact: MEDIUM**
|
|
1580
|
+
|
|
1581
|
+
Ground truth principles for state variables and derived values.
|
|
1582
|
+
|
|
1583
|
+
### 7.1 State Must Represent Ground Truth
|
|
1584
|
+
|
|
1585
|
+
**Impact: HIGH (cleaner logic, easier debugging, single source of truth)**
|
|
1586
|
+
|
|
1587
|
+
State variables—both React `useState` and Reanimated shared values—should
|
|
1588
|
+
|
|
1589
|
+
represent the actual state of something (e.g., `pressed`, `progress`, `isOpen`),
|
|
1590
|
+
|
|
1591
|
+
not derived visual values (e.g., `scale`, `opacity`, `translateY`). Derive
|
|
1592
|
+
|
|
1593
|
+
visual values from state using computation or interpolation.
|
|
1594
|
+
|
|
1595
|
+
**Incorrect: storing the visual output**
|
|
1596
|
+
|
|
1597
|
+
```tsx
|
|
1598
|
+
const scale = useSharedValue(1)
|
|
1599
|
+
|
|
1600
|
+
const tap = Gesture.Tap()
|
|
1601
|
+
.onBegin(() => {
|
|
1602
|
+
scale.set(withTiming(0.95))
|
|
1603
|
+
})
|
|
1604
|
+
.onFinalize(() => {
|
|
1605
|
+
scale.set(withTiming(1))
|
|
1606
|
+
})
|
|
1607
|
+
|
|
1608
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
1609
|
+
transform: [{ scale: scale.get() }],
|
|
1610
|
+
}))
|
|
1611
|
+
```
|
|
1612
|
+
|
|
1613
|
+
**Correct: storing the state, deriving the visual**
|
|
1614
|
+
|
|
1615
|
+
```tsx
|
|
1616
|
+
const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed
|
|
1617
|
+
|
|
1618
|
+
const tap = Gesture.Tap()
|
|
1619
|
+
.onBegin(() => {
|
|
1620
|
+
pressed.set(withTiming(1))
|
|
1621
|
+
})
|
|
1622
|
+
.onFinalize(() => {
|
|
1623
|
+
pressed.set(withTiming(0))
|
|
1624
|
+
})
|
|
1625
|
+
|
|
1626
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
1627
|
+
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
|
|
1628
|
+
}))
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
**Why this matters:**
|
|
1632
|
+
|
|
1633
|
+
State variables should represent real "state", not necessarily a desired end
|
|
1634
|
+
|
|
1635
|
+
result.
|
|
1636
|
+
|
|
1637
|
+
1. **Single source of truth** — The state (`pressed`) describes what's
|
|
1638
|
+
|
|
1639
|
+
happening; visuals are derived
|
|
1640
|
+
|
|
1641
|
+
2. **Easier to extend** — Adding opacity, rotation, or other effects just
|
|
1642
|
+
|
|
1643
|
+
requires more interpolations from the same state
|
|
1644
|
+
|
|
1645
|
+
3. **Debugging** — Inspecting `pressed = 1` is clearer than `scale = 0.95`
|
|
1646
|
+
|
|
1647
|
+
4. **Reusable logic** — The same `pressed` value can drive multiple visual
|
|
1648
|
+
|
|
1649
|
+
properties
|
|
1650
|
+
|
|
1651
|
+
**Same principle for React state:**
|
|
1652
|
+
|
|
1653
|
+
```tsx
|
|
1654
|
+
// Incorrect: storing derived values
|
|
1655
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
1656
|
+
const [height, setHeight] = useState(0)
|
|
1657
|
+
|
|
1658
|
+
useEffect(() => {
|
|
1659
|
+
setHeight(isExpanded ? 200 : 0)
|
|
1660
|
+
}, [isExpanded])
|
|
1661
|
+
|
|
1662
|
+
// Correct: derive from state
|
|
1663
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
1664
|
+
const height = isExpanded ? 200 : 0
|
|
1665
|
+
```
|
|
1666
|
+
|
|
1667
|
+
State is the minimal truth. Everything else is derived.
|
|
1668
|
+
|
|
1669
|
+
---
|
|
1670
|
+
|
|
1671
|
+
## 8. React Compiler
|
|
1672
|
+
|
|
1673
|
+
**Impact: MEDIUM**
|
|
1674
|
+
|
|
1675
|
+
Compatibility patterns for React Compiler with React Native and
|
|
1676
|
+
Reanimated.
|
|
1677
|
+
|
|
1678
|
+
### 8.1 Destructure Functions Early in Render (React Compiler)
|
|
1679
|
+
|
|
1680
|
+
**Impact: HIGH (stable references, fewer re-renders)**
|
|
1681
|
+
|
|
1682
|
+
This rule is only applicable if you are using the React Compiler.
|
|
1683
|
+
|
|
1684
|
+
Destructure functions from hooks at the top of render scope. Never dot into
|
|
1685
|
+
|
|
1686
|
+
objects to call functions. Destructured functions are stable references; dotting
|
|
1687
|
+
|
|
1688
|
+
creates new references and breaks memoization.
|
|
1689
|
+
|
|
1690
|
+
**Incorrect: dotting into object**
|
|
1691
|
+
|
|
1692
|
+
```tsx
|
|
1693
|
+
import { useRouter } from 'expo-router'
|
|
1694
|
+
|
|
1695
|
+
function SaveButton(props) {
|
|
1696
|
+
const router = useRouter()
|
|
1697
|
+
|
|
1698
|
+
// bad: react-compiler will key the cache on "props" and "router", which are objects that change each render
|
|
1699
|
+
const handlePress = () => {
|
|
1700
|
+
props.onSave()
|
|
1701
|
+
router.push('/success') // unstable reference
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
return <Button onPress={handlePress}>Save</Button>
|
|
1705
|
+
}
|
|
1706
|
+
```
|
|
1707
|
+
|
|
1708
|
+
**Correct: destructure early**
|
|
1709
|
+
|
|
1710
|
+
```tsx
|
|
1711
|
+
import { useRouter } from 'expo-router'
|
|
1712
|
+
|
|
1713
|
+
function SaveButton({ onSave }) {
|
|
1714
|
+
const { push } = useRouter()
|
|
1715
|
+
|
|
1716
|
+
// good: react-compiler will key on push and onSave
|
|
1717
|
+
const handlePress = () => {
|
|
1718
|
+
onSave()
|
|
1719
|
+
push('/success') // stable reference
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
return <Button onPress={handlePress}>Save</Button>
|
|
1723
|
+
}
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
### 8.2 Use .get() and .set() for Reanimated Shared Values (not .value)
|
|
1727
|
+
|
|
1728
|
+
**Impact: LOW (required for React Compiler compatibility)**
|
|
1729
|
+
|
|
1730
|
+
With React Compiler enabled, use `.get()` and `.set()` instead of reading or
|
|
1731
|
+
|
|
1732
|
+
writing `.value` directly on Reanimated shared values. The compiler can't track
|
|
1733
|
+
|
|
1734
|
+
property access—explicit methods ensure correct behavior.
|
|
1735
|
+
|
|
1736
|
+
**Incorrect: breaks with React Compiler**
|
|
1737
|
+
|
|
1738
|
+
```tsx
|
|
1739
|
+
import { useSharedValue } from 'react-native-reanimated'
|
|
1740
|
+
|
|
1741
|
+
function Counter() {
|
|
1742
|
+
const count = useSharedValue(0)
|
|
1743
|
+
|
|
1744
|
+
const increment = () => {
|
|
1745
|
+
count.value = count.value + 1 // opts out of react compiler
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return <Button onPress={increment} title={`Count: ${count.value}`} />
|
|
1749
|
+
}
|
|
1750
|
+
```
|
|
1751
|
+
|
|
1752
|
+
**Correct: React Compiler compatible**
|
|
1753
|
+
|
|
1754
|
+
```tsx
|
|
1755
|
+
import { useSharedValue } from 'react-native-reanimated'
|
|
1756
|
+
|
|
1757
|
+
function Counter() {
|
|
1758
|
+
const count = useSharedValue(0)
|
|
1759
|
+
|
|
1760
|
+
const increment = () => {
|
|
1761
|
+
count.set(count.get() + 1)
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
return <Button onPress={increment} title={`Count: ${count.get()}`} />
|
|
1765
|
+
}
|
|
1766
|
+
```
|
|
1767
|
+
|
|
1768
|
+
See the
|
|
1769
|
+
|
|
1770
|
+
[Reanimated docs](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/#react-compiler-support)
|
|
1771
|
+
|
|
1772
|
+
for more.
|
|
1773
|
+
|
|
1774
|
+
---
|
|
1775
|
+
|
|
1776
|
+
## 9. User Interface
|
|
1777
|
+
|
|
1778
|
+
**Impact: MEDIUM**
|
|
1779
|
+
|
|
1780
|
+
Native UI patterns for images, menus, modals, styling, and
|
|
1781
|
+
platform-consistent interfaces.
|
|
1782
|
+
|
|
1783
|
+
### 9.1 Measuring View Dimensions
|
|
1784
|
+
|
|
1785
|
+
**Impact: MEDIUM (synchronous measurement, avoid unnecessary re-renders)**
|
|
1786
|
+
|
|
1787
|
+
Use both `useLayoutEffect` (synchronous) and `onLayout` (for updates). The sync
|
|
1788
|
+
|
|
1789
|
+
measurement gives you the initial size immediately; `onLayout` keeps it current
|
|
1790
|
+
|
|
1791
|
+
when the view changes. For non-primitive states, use a dispatch updater to
|
|
1792
|
+
|
|
1793
|
+
compare values and avoid unnecessary re-renders.
|
|
1794
|
+
|
|
1795
|
+
**Height only:**
|
|
1796
|
+
|
|
1797
|
+
```tsx
|
|
1798
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
1799
|
+
import { View, LayoutChangeEvent } from 'react-native'
|
|
1800
|
+
|
|
1801
|
+
function MeasuredBox({ children }: { children: React.ReactNode }) {
|
|
1802
|
+
const ref = useRef<View>(null)
|
|
1803
|
+
const [height, setHeight] = useState<number | undefined>(undefined)
|
|
1804
|
+
|
|
1805
|
+
useLayoutEffect(() => {
|
|
1806
|
+
// Sync measurement on mount (RN 0.82+)
|
|
1807
|
+
const rect = ref.current?.getBoundingClientRect()
|
|
1808
|
+
if (rect) setHeight(rect.height)
|
|
1809
|
+
// Pre-0.82: ref.current?.measure((x, y, w, h) => setHeight(h))
|
|
1810
|
+
}, [])
|
|
1811
|
+
|
|
1812
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
1813
|
+
setHeight(e.nativeEvent.layout.height)
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
return (
|
|
1817
|
+
<View ref={ref} onLayout={onLayout}>
|
|
1818
|
+
{children}
|
|
1819
|
+
</View>
|
|
1820
|
+
)
|
|
1821
|
+
}
|
|
1822
|
+
```
|
|
1823
|
+
|
|
1824
|
+
**Both dimensions:**
|
|
1825
|
+
|
|
1826
|
+
```tsx
|
|
1827
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
1828
|
+
import { View, LayoutChangeEvent } from 'react-native'
|
|
1829
|
+
|
|
1830
|
+
type Size = { width: number; height: number }
|
|
1831
|
+
|
|
1832
|
+
function MeasuredBox({ children }: { children: React.ReactNode }) {
|
|
1833
|
+
const ref = useRef<View>(null)
|
|
1834
|
+
const [size, setSize] = useState<Size | undefined>(undefined)
|
|
1835
|
+
|
|
1836
|
+
useLayoutEffect(() => {
|
|
1837
|
+
const rect = ref.current?.getBoundingClientRect()
|
|
1838
|
+
if (rect) setSize({ width: rect.width, height: rect.height })
|
|
1839
|
+
}, [])
|
|
1840
|
+
|
|
1841
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
1842
|
+
const { width, height } = e.nativeEvent.layout
|
|
1843
|
+
setSize((prev) => {
|
|
1844
|
+
// for non-primitive states, compare values before firing a re-render
|
|
1845
|
+
if (prev?.width === width && prev?.height === height) return prev
|
|
1846
|
+
return { width, height }
|
|
1847
|
+
})
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
return (
|
|
1851
|
+
<View ref={ref} onLayout={onLayout}>
|
|
1852
|
+
{children}
|
|
1853
|
+
</View>
|
|
1854
|
+
)
|
|
1855
|
+
}
|
|
1856
|
+
```
|
|
1857
|
+
|
|
1858
|
+
Use functional setState to compare—don't read state directly in the callback.
|
|
1859
|
+
|
|
1860
|
+
### 9.2 Modern React Native Styling Patterns
|
|
1861
|
+
|
|
1862
|
+
**Impact: MEDIUM (consistent design, smoother borders, cleaner layouts)**
|
|
1863
|
+
|
|
1864
|
+
Follow these styling patterns for cleaner, more consistent React Native code.
|
|
1865
|
+
|
|
1866
|
+
**Always use `borderCurve: 'continuous'` with `borderRadius`:**
|
|
1867
|
+
|
|
1868
|
+
**Use `gap` instead of margin for spacing between elements:**
|
|
1869
|
+
|
|
1870
|
+
```tsx
|
|
1871
|
+
// Incorrect – margin on children
|
|
1872
|
+
<View>
|
|
1873
|
+
<Text style={{ marginBottom: 8 }}>Title</Text>
|
|
1874
|
+
<Text style={{ marginBottom: 8 }}>Subtitle</Text>
|
|
1875
|
+
</View>
|
|
1876
|
+
|
|
1877
|
+
// Correct – gap on parent
|
|
1878
|
+
<View style={{ gap: 8 }}>
|
|
1879
|
+
<Text>Title</Text>
|
|
1880
|
+
<Text>Subtitle</Text>
|
|
1881
|
+
</View>
|
|
1882
|
+
```
|
|
1883
|
+
|
|
1884
|
+
**Use `padding` for space within, `gap` for space between:**
|
|
1885
|
+
|
|
1886
|
+
```tsx
|
|
1887
|
+
<View style={{ padding: 16, gap: 12 }}>
|
|
1888
|
+
<Text>First</Text>
|
|
1889
|
+
<Text>Second</Text>
|
|
1890
|
+
</View>
|
|
1891
|
+
```
|
|
1892
|
+
|
|
1893
|
+
**Use `experimental_backgroundImage` for linear gradients:**
|
|
1894
|
+
|
|
1895
|
+
```tsx
|
|
1896
|
+
// Incorrect – third-party gradient library
|
|
1897
|
+
<LinearGradient colors={['#000', '#fff']} />
|
|
1898
|
+
|
|
1899
|
+
// Correct – native CSS gradient syntax
|
|
1900
|
+
<View
|
|
1901
|
+
style={{
|
|
1902
|
+
experimental_backgroundImage: 'linear-gradient(to bottom, #000, #fff)',
|
|
1903
|
+
}}
|
|
1904
|
+
/>
|
|
1905
|
+
```
|
|
1906
|
+
|
|
1907
|
+
**Use CSS `boxShadow` string syntax for shadows:**
|
|
1908
|
+
|
|
1909
|
+
```tsx
|
|
1910
|
+
// Incorrect – legacy shadow objects or elevation
|
|
1911
|
+
{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
|
|
1912
|
+
{ elevation: 4 }
|
|
1913
|
+
|
|
1914
|
+
// Correct – CSS box-shadow syntax
|
|
1915
|
+
{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }
|
|
1916
|
+
```
|
|
1917
|
+
|
|
1918
|
+
**Avoid multiple font sizes – use weight and color for emphasis:**
|
|
1919
|
+
|
|
1920
|
+
```tsx
|
|
1921
|
+
// Incorrect – varying font sizes for hierarchy
|
|
1922
|
+
<Text style={{ fontSize: 18 }}>Title</Text>
|
|
1923
|
+
<Text style={{ fontSize: 14 }}>Subtitle</Text>
|
|
1924
|
+
<Text style={{ fontSize: 12 }}>Caption</Text>
|
|
1925
|
+
|
|
1926
|
+
// Correct – consistent size, vary weight and color
|
|
1927
|
+
<Text style={{ fontWeight: '600' }}>Title</Text>
|
|
1928
|
+
<Text style={{ color: '#666' }}>Subtitle</Text>
|
|
1929
|
+
<Text style={{ color: '#999' }}>Caption</Text>
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
Limiting font sizes creates visual consistency. Use `fontWeight` (bold/semibold)
|
|
1933
|
+
|
|
1934
|
+
and grayscale colors for hierarchy instead.
|
|
1935
|
+
|
|
1936
|
+
### 9.3 Use contentInset for Dynamic ScrollView Spacing
|
|
1937
|
+
|
|
1938
|
+
**Impact: LOW (smoother updates, no layout recalculation)**
|
|
1939
|
+
|
|
1940
|
+
When adding space to the top or bottom of a ScrollView that may change
|
|
1941
|
+
|
|
1942
|
+
(keyboard, toolbars, dynamic content), use `contentInset` instead of padding.
|
|
1943
|
+
|
|
1944
|
+
Changing `contentInset` doesn't trigger layout recalculation—it adjusts the
|
|
1945
|
+
|
|
1946
|
+
scroll area without re-rendering content.
|
|
1947
|
+
|
|
1948
|
+
**Incorrect: padding causes layout recalculation**
|
|
1949
|
+
|
|
1950
|
+
```tsx
|
|
1951
|
+
function Feed({ bottomOffset }: { bottomOffset: number }) {
|
|
1952
|
+
return (
|
|
1953
|
+
<ScrollView contentContainerStyle={{ paddingBottom: bottomOffset }}>
|
|
1954
|
+
{children}
|
|
1955
|
+
</ScrollView>
|
|
1956
|
+
)
|
|
1957
|
+
}
|
|
1958
|
+
// Changing bottomOffset triggers full layout recalculation
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
**Correct: contentInset for dynamic spacing**
|
|
1962
|
+
|
|
1963
|
+
```tsx
|
|
1964
|
+
function Feed({ bottomOffset }: { bottomOffset: number }) {
|
|
1965
|
+
return (
|
|
1966
|
+
<ScrollView
|
|
1967
|
+
contentInset={{ bottom: bottomOffset }}
|
|
1968
|
+
scrollIndicatorInsets={{ bottom: bottomOffset }}
|
|
1969
|
+
>
|
|
1970
|
+
{children}
|
|
1971
|
+
</ScrollView>
|
|
1972
|
+
)
|
|
1973
|
+
}
|
|
1974
|
+
// Changing bottomOffset only adjusts scroll bounds
|
|
1975
|
+
```
|
|
1976
|
+
|
|
1977
|
+
Use `scrollIndicatorInsets` alongside `contentInset` to keep the scroll
|
|
1978
|
+
|
|
1979
|
+
indicator aligned. For static spacing that never changes, padding is fine.
|
|
1980
|
+
|
|
1981
|
+
### 9.4 Use contentInsetAdjustmentBehavior for Safe Areas
|
|
1982
|
+
|
|
1983
|
+
**Impact: MEDIUM (native safe area handling, no layout shifts)**
|
|
1984
|
+
|
|
1985
|
+
Use `contentInsetAdjustmentBehavior="automatic"` on the root ScrollView instead of wrapping content in SafeAreaView or manual padding. This lets iOS handle safe area insets natively with proper scroll behavior.
|
|
1986
|
+
|
|
1987
|
+
**Incorrect: SafeAreaView wrapper**
|
|
1988
|
+
|
|
1989
|
+
```tsx
|
|
1990
|
+
import { SafeAreaView, ScrollView, View, Text } from 'react-native'
|
|
1991
|
+
|
|
1992
|
+
function MyScreen() {
|
|
1993
|
+
return (
|
|
1994
|
+
<SafeAreaView style={{ flex: 1 }}>
|
|
1995
|
+
<ScrollView>
|
|
1996
|
+
<View>
|
|
1997
|
+
<Text>Content</Text>
|
|
1998
|
+
</View>
|
|
1999
|
+
</ScrollView>
|
|
2000
|
+
</SafeAreaView>
|
|
2001
|
+
)
|
|
2002
|
+
}
|
|
2003
|
+
```
|
|
2004
|
+
|
|
2005
|
+
**Incorrect: manual safe area padding**
|
|
2006
|
+
|
|
2007
|
+
```tsx
|
|
2008
|
+
import { ScrollView, View, Text } from 'react-native'
|
|
2009
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
2010
|
+
|
|
2011
|
+
function MyScreen() {
|
|
2012
|
+
const insets = useSafeAreaInsets()
|
|
2013
|
+
|
|
2014
|
+
return (
|
|
2015
|
+
<ScrollView contentContainerStyle={{ paddingTop: insets.top }}>
|
|
2016
|
+
<View>
|
|
2017
|
+
<Text>Content</Text>
|
|
2018
|
+
</View>
|
|
2019
|
+
</ScrollView>
|
|
2020
|
+
)
|
|
2021
|
+
}
|
|
2022
|
+
```
|
|
2023
|
+
|
|
2024
|
+
**Correct: native content inset adjustment**
|
|
2025
|
+
|
|
2026
|
+
```tsx
|
|
2027
|
+
import { ScrollView, View, Text } from 'react-native'
|
|
2028
|
+
|
|
2029
|
+
function MyScreen() {
|
|
2030
|
+
return (
|
|
2031
|
+
<ScrollView contentInsetAdjustmentBehavior='automatic'>
|
|
2032
|
+
<View>
|
|
2033
|
+
<Text>Content</Text>
|
|
2034
|
+
</View>
|
|
2035
|
+
</ScrollView>
|
|
2036
|
+
)
|
|
2037
|
+
}
|
|
2038
|
+
```
|
|
2039
|
+
|
|
2040
|
+
The native approach handles dynamic safe areas (keyboard, toolbars) and allows content to scroll behind the status bar naturally.
|
|
2041
|
+
|
|
2042
|
+
### 9.5 Use expo-image for Optimized Images
|
|
2043
|
+
|
|
2044
|
+
**Impact: HIGH (memory efficiency, caching, blurhash placeholders, progressive loading)**
|
|
2045
|
+
|
|
2046
|
+
Use `expo-image` instead of React Native's `Image`. It provides memory-efficient caching, blurhash placeholders, progressive loading, and better performance for lists.
|
|
2047
|
+
|
|
2048
|
+
**Incorrect: React Native Image**
|
|
2049
|
+
|
|
2050
|
+
```tsx
|
|
2051
|
+
import { Image } from 'react-native'
|
|
2052
|
+
|
|
2053
|
+
function Avatar({ url }: { url: string }) {
|
|
2054
|
+
return <Image source={{ uri: url }} style={styles.avatar} />
|
|
2055
|
+
}
|
|
2056
|
+
```
|
|
2057
|
+
|
|
2058
|
+
**Correct: expo-image**
|
|
2059
|
+
|
|
2060
|
+
```tsx
|
|
2061
|
+
import { Image } from 'expo-image'
|
|
2062
|
+
|
|
2063
|
+
function Avatar({ url }: { url: string }) {
|
|
2064
|
+
return <Image source={{ uri: url }} style={styles.avatar} />
|
|
2065
|
+
}
|
|
2066
|
+
```
|
|
2067
|
+
|
|
2068
|
+
**With blurhash placeholder:**
|
|
2069
|
+
|
|
2070
|
+
```tsx
|
|
2071
|
+
<Image
|
|
2072
|
+
source={{ uri: url }}
|
|
2073
|
+
placeholder={{ blurhash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.' }}
|
|
2074
|
+
contentFit="cover"
|
|
2075
|
+
transition={200}
|
|
2076
|
+
style={styles.image}
|
|
2077
|
+
/>
|
|
2078
|
+
```
|
|
2079
|
+
|
|
2080
|
+
**With priority and caching:**
|
|
2081
|
+
|
|
2082
|
+
```tsx
|
|
2083
|
+
<Image
|
|
2084
|
+
source={{ uri: url }}
|
|
2085
|
+
priority="high"
|
|
2086
|
+
cachePolicy="memory-disk"
|
|
2087
|
+
style={styles.hero}
|
|
2088
|
+
/>
|
|
2089
|
+
```
|
|
2090
|
+
|
|
2091
|
+
**Key props:**
|
|
2092
|
+
|
|
2093
|
+
- `placeholder` — Blurhash or thumbnail while loading
|
|
2094
|
+
|
|
2095
|
+
- `contentFit` — `cover`, `contain`, `fill`, `scale-down`
|
|
2096
|
+
|
|
2097
|
+
- `transition` — Fade-in duration (ms)
|
|
2098
|
+
|
|
2099
|
+
- `priority` — `low`, `normal`, `high`
|
|
2100
|
+
|
|
2101
|
+
- `cachePolicy` — `memory`, `disk`, `memory-disk`, `none`
|
|
2102
|
+
|
|
2103
|
+
- `recyclingKey` — Unique key for list recycling
|
|
2104
|
+
|
|
2105
|
+
For cross-platform (web + native), use `SolitoImage` from `solito/image` which uses `expo-image` under the hood.
|
|
2106
|
+
|
|
2107
|
+
Reference: [https://docs.expo.dev/versions/latest/sdk/image/](https://docs.expo.dev/versions/latest/sdk/image/)
|
|
2108
|
+
|
|
2109
|
+
### 9.6 Use Galeria for Image Galleries and Lightbox
|
|
2110
|
+
|
|
2111
|
+
**Impact: MEDIUM**
|
|
2112
|
+
|
|
2113
|
+
For image galleries with lightbox (tap to fullscreen), use `@nandorojo/galeria`.
|
|
2114
|
+
|
|
2115
|
+
It provides native shared element transitions with pinch-to-zoom, double-tap
|
|
2116
|
+
|
|
2117
|
+
zoom, and pan-to-close. Works with any image component including `expo-image`.
|
|
2118
|
+
|
|
2119
|
+
**Incorrect: custom modal implementation**
|
|
2120
|
+
|
|
2121
|
+
```tsx
|
|
2122
|
+
function ImageGallery({ urls }: { urls: string[] }) {
|
|
2123
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
2124
|
+
|
|
2125
|
+
return (
|
|
2126
|
+
<>
|
|
2127
|
+
{urls.map((url) => (
|
|
2128
|
+
<Pressable key={url} onPress={() => setSelected(url)}>
|
|
2129
|
+
<Image source={{ uri: url }} style={styles.thumbnail} />
|
|
2130
|
+
</Pressable>
|
|
2131
|
+
))}
|
|
2132
|
+
<Modal visible={!!selected} onRequestClose={() => setSelected(null)}>
|
|
2133
|
+
<Image source={{ uri: selected! }} style={styles.fullscreen} />
|
|
2134
|
+
</Modal>
|
|
2135
|
+
</>
|
|
2136
|
+
)
|
|
2137
|
+
}
|
|
2138
|
+
```
|
|
2139
|
+
|
|
2140
|
+
**Correct: Galeria with expo-image**
|
|
2141
|
+
|
|
2142
|
+
```tsx
|
|
2143
|
+
import { Galeria } from '@nandorojo/galeria'
|
|
2144
|
+
import { Image } from 'expo-image'
|
|
2145
|
+
|
|
2146
|
+
function ImageGallery({ urls }: { urls: string[] }) {
|
|
2147
|
+
return (
|
|
2148
|
+
<Galeria urls={urls}>
|
|
2149
|
+
{urls.map((url, index) => (
|
|
2150
|
+
<Galeria.Image index={index} key={url}>
|
|
2151
|
+
<Image source={{ uri: url }} style={styles.thumbnail} />
|
|
2152
|
+
</Galeria.Image>
|
|
2153
|
+
))}
|
|
2154
|
+
</Galeria>
|
|
2155
|
+
)
|
|
2156
|
+
}
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
**Single image:**
|
|
2160
|
+
|
|
2161
|
+
```tsx
|
|
2162
|
+
import { Galeria } from '@nandorojo/galeria'
|
|
2163
|
+
import { Image } from 'expo-image'
|
|
2164
|
+
|
|
2165
|
+
function Avatar({ url }: { url: string }) {
|
|
2166
|
+
return (
|
|
2167
|
+
<Galeria urls={[url]}>
|
|
2168
|
+
<Galeria.Image>
|
|
2169
|
+
<Image source={{ uri: url }} style={styles.avatar} />
|
|
2170
|
+
</Galeria.Image>
|
|
2171
|
+
</Galeria>
|
|
2172
|
+
)
|
|
2173
|
+
}
|
|
2174
|
+
```
|
|
2175
|
+
|
|
2176
|
+
**With low-res thumbnails and high-res fullscreen:**
|
|
2177
|
+
|
|
2178
|
+
```tsx
|
|
2179
|
+
<Galeria urls={highResUrls}>
|
|
2180
|
+
{lowResUrls.map((url, index) => (
|
|
2181
|
+
<Galeria.Image index={index} key={url}>
|
|
2182
|
+
<Image source={{ uri: url }} style={styles.thumbnail} />
|
|
2183
|
+
</Galeria.Image>
|
|
2184
|
+
))}
|
|
2185
|
+
</Galeria>
|
|
2186
|
+
```
|
|
2187
|
+
|
|
2188
|
+
**With FlashList:**
|
|
2189
|
+
|
|
2190
|
+
```tsx
|
|
2191
|
+
<Galeria urls={urls}>
|
|
2192
|
+
<FlashList
|
|
2193
|
+
data={urls}
|
|
2194
|
+
renderItem={({ item, index }) => (
|
|
2195
|
+
<Galeria.Image index={index}>
|
|
2196
|
+
<Image source={{ uri: item }} style={styles.thumbnail} />
|
|
2197
|
+
</Galeria.Image>
|
|
2198
|
+
)}
|
|
2199
|
+
numColumns={3}
|
|
2200
|
+
estimatedItemSize={100}
|
|
2201
|
+
/>
|
|
2202
|
+
</Galeria>
|
|
2203
|
+
```
|
|
2204
|
+
|
|
2205
|
+
Works with `expo-image`, `SolitoImage`, `react-native` Image, or any image
|
|
2206
|
+
|
|
2207
|
+
component.
|
|
2208
|
+
|
|
2209
|
+
Reference: [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria)
|
|
2210
|
+
|
|
2211
|
+
### 9.7 Use Native Menus for Dropdowns and Context Menus
|
|
2212
|
+
|
|
2213
|
+
**Impact: HIGH (native accessibility, platform-consistent UX)**
|
|
2214
|
+
|
|
2215
|
+
Use native platform menus instead of custom JS implementations. Native menus
|
|
2216
|
+
|
|
2217
|
+
provide built-in accessibility, consistent platform UX, and better performance.
|
|
2218
|
+
|
|
2219
|
+
Use [zeego](https://zeego.dev) for cross-platform native menus.
|
|
2220
|
+
|
|
2221
|
+
**Incorrect: custom JS menu**
|
|
2222
|
+
|
|
2223
|
+
```tsx
|
|
2224
|
+
import { useState } from 'react'
|
|
2225
|
+
import { View, Pressable, Text } from 'react-native'
|
|
2226
|
+
|
|
2227
|
+
function MyMenu() {
|
|
2228
|
+
const [open, setOpen] = useState(false)
|
|
2229
|
+
|
|
2230
|
+
return (
|
|
2231
|
+
<View>
|
|
2232
|
+
<Pressable onPress={() => setOpen(!open)}>
|
|
2233
|
+
<Text>Open Menu</Text>
|
|
2234
|
+
</Pressable>
|
|
2235
|
+
{open && (
|
|
2236
|
+
<View style={{ position: 'absolute', top: 40 }}>
|
|
2237
|
+
<Pressable onPress={() => console.log('edit')}>
|
|
2238
|
+
<Text>Edit</Text>
|
|
2239
|
+
</Pressable>
|
|
2240
|
+
<Pressable onPress={() => console.log('delete')}>
|
|
2241
|
+
<Text>Delete</Text>
|
|
2242
|
+
</Pressable>
|
|
2243
|
+
</View>
|
|
2244
|
+
)}
|
|
2245
|
+
</View>
|
|
2246
|
+
)
|
|
2247
|
+
}
|
|
2248
|
+
```
|
|
2249
|
+
|
|
2250
|
+
**Correct: native menu with zeego**
|
|
2251
|
+
|
|
2252
|
+
```tsx
|
|
2253
|
+
import * as DropdownMenu from 'zeego/dropdown-menu'
|
|
2254
|
+
|
|
2255
|
+
function MyMenu() {
|
|
2256
|
+
return (
|
|
2257
|
+
<DropdownMenu.Root>
|
|
2258
|
+
<DropdownMenu.Trigger>
|
|
2259
|
+
<Pressable>
|
|
2260
|
+
<Text>Open Menu</Text>
|
|
2261
|
+
</Pressable>
|
|
2262
|
+
</DropdownMenu.Trigger>
|
|
2263
|
+
|
|
2264
|
+
<DropdownMenu.Content>
|
|
2265
|
+
<DropdownMenu.Item key='edit' onSelect={() => console.log('edit')}>
|
|
2266
|
+
<DropdownMenu.ItemTitle>Edit</DropdownMenu.ItemTitle>
|
|
2267
|
+
</DropdownMenu.Item>
|
|
2268
|
+
|
|
2269
|
+
<DropdownMenu.Item
|
|
2270
|
+
key='delete'
|
|
2271
|
+
destructive
|
|
2272
|
+
onSelect={() => console.log('delete')}
|
|
2273
|
+
>
|
|
2274
|
+
<DropdownMenu.ItemTitle>Delete</DropdownMenu.ItemTitle>
|
|
2275
|
+
</DropdownMenu.Item>
|
|
2276
|
+
</DropdownMenu.Content>
|
|
2277
|
+
</DropdownMenu.Root>
|
|
2278
|
+
)
|
|
2279
|
+
}
|
|
2280
|
+
```
|
|
2281
|
+
|
|
2282
|
+
**Context menu: long-press**
|
|
2283
|
+
|
|
2284
|
+
```tsx
|
|
2285
|
+
import * as ContextMenu from 'zeego/context-menu'
|
|
2286
|
+
|
|
2287
|
+
function MyContextMenu() {
|
|
2288
|
+
return (
|
|
2289
|
+
<ContextMenu.Root>
|
|
2290
|
+
<ContextMenu.Trigger>
|
|
2291
|
+
<View style={{ padding: 20 }}>
|
|
2292
|
+
<Text>Long press me</Text>
|
|
2293
|
+
</View>
|
|
2294
|
+
</ContextMenu.Trigger>
|
|
2295
|
+
|
|
2296
|
+
<ContextMenu.Content>
|
|
2297
|
+
<ContextMenu.Item key='copy' onSelect={() => console.log('copy')}>
|
|
2298
|
+
<ContextMenu.ItemTitle>Copy</ContextMenu.ItemTitle>
|
|
2299
|
+
</ContextMenu.Item>
|
|
2300
|
+
|
|
2301
|
+
<ContextMenu.Item key='paste' onSelect={() => console.log('paste')}>
|
|
2302
|
+
<ContextMenu.ItemTitle>Paste</ContextMenu.ItemTitle>
|
|
2303
|
+
</ContextMenu.Item>
|
|
2304
|
+
</ContextMenu.Content>
|
|
2305
|
+
</ContextMenu.Root>
|
|
2306
|
+
)
|
|
2307
|
+
}
|
|
2308
|
+
```
|
|
2309
|
+
|
|
2310
|
+
**Checkbox items:**
|
|
2311
|
+
|
|
2312
|
+
```tsx
|
|
2313
|
+
import * as DropdownMenu from 'zeego/dropdown-menu'
|
|
2314
|
+
|
|
2315
|
+
function SettingsMenu() {
|
|
2316
|
+
const [notifications, setNotifications] = useState(true)
|
|
2317
|
+
|
|
2318
|
+
return (
|
|
2319
|
+
<DropdownMenu.Root>
|
|
2320
|
+
<DropdownMenu.Trigger>
|
|
2321
|
+
<Pressable>
|
|
2322
|
+
<Text>Settings</Text>
|
|
2323
|
+
</Pressable>
|
|
2324
|
+
</DropdownMenu.Trigger>
|
|
2325
|
+
|
|
2326
|
+
<DropdownMenu.Content>
|
|
2327
|
+
<DropdownMenu.CheckboxItem
|
|
2328
|
+
key='notifications'
|
|
2329
|
+
value={notifications}
|
|
2330
|
+
onValueChange={() => setNotifications((prev) => !prev)}
|
|
2331
|
+
>
|
|
2332
|
+
<DropdownMenu.ItemIndicator />
|
|
2333
|
+
<DropdownMenu.ItemTitle>Notifications</DropdownMenu.ItemTitle>
|
|
2334
|
+
</DropdownMenu.CheckboxItem>
|
|
2335
|
+
</DropdownMenu.Content>
|
|
2336
|
+
</DropdownMenu.Root>
|
|
2337
|
+
)
|
|
2338
|
+
}
|
|
2339
|
+
```
|
|
2340
|
+
|
|
2341
|
+
**Submenus:**
|
|
2342
|
+
|
|
2343
|
+
```tsx
|
|
2344
|
+
import * as DropdownMenu from 'zeego/dropdown-menu'
|
|
2345
|
+
|
|
2346
|
+
function MenuWithSubmenu() {
|
|
2347
|
+
return (
|
|
2348
|
+
<DropdownMenu.Root>
|
|
2349
|
+
<DropdownMenu.Trigger>
|
|
2350
|
+
<Pressable>
|
|
2351
|
+
<Text>Options</Text>
|
|
2352
|
+
</Pressable>
|
|
2353
|
+
</DropdownMenu.Trigger>
|
|
2354
|
+
|
|
2355
|
+
<DropdownMenu.Content>
|
|
2356
|
+
<DropdownMenu.Item key='home' onSelect={() => console.log('home')}>
|
|
2357
|
+
<DropdownMenu.ItemTitle>Home</DropdownMenu.ItemTitle>
|
|
2358
|
+
</DropdownMenu.Item>
|
|
2359
|
+
|
|
2360
|
+
<DropdownMenu.Sub>
|
|
2361
|
+
<DropdownMenu.SubTrigger key='more'>
|
|
2362
|
+
<DropdownMenu.ItemTitle>More Options</DropdownMenu.ItemTitle>
|
|
2363
|
+
</DropdownMenu.SubTrigger>
|
|
2364
|
+
|
|
2365
|
+
<DropdownMenu.SubContent>
|
|
2366
|
+
<DropdownMenu.Item key='settings'>
|
|
2367
|
+
<DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
|
|
2368
|
+
</DropdownMenu.Item>
|
|
2369
|
+
|
|
2370
|
+
<DropdownMenu.Item key='help'>
|
|
2371
|
+
<DropdownMenu.ItemTitle>Help</DropdownMenu.ItemTitle>
|
|
2372
|
+
</DropdownMenu.Item>
|
|
2373
|
+
</DropdownMenu.SubContent>
|
|
2374
|
+
</DropdownMenu.Sub>
|
|
2375
|
+
</DropdownMenu.Content>
|
|
2376
|
+
</DropdownMenu.Root>
|
|
2377
|
+
)
|
|
2378
|
+
}
|
|
2379
|
+
```
|
|
2380
|
+
|
|
2381
|
+
Reference: [https://zeego.dev/components/dropdown-menu](https://zeego.dev/components/dropdown-menu)
|
|
2382
|
+
|
|
2383
|
+
### 9.8 Use Native Modals Over JS-Based Bottom Sheets
|
|
2384
|
+
|
|
2385
|
+
**Impact: HIGH (native performance, gestures, accessibility)**
|
|
2386
|
+
|
|
2387
|
+
Use native `<Modal>` with `presentationStyle="formSheet"` or React Navigation
|
|
2388
|
+
|
|
2389
|
+
v7's native form sheet instead of JS-based bottom sheet libraries. Native modals
|
|
2390
|
+
|
|
2391
|
+
have built-in gestures, accessibility, and better performance. Rely on native UI
|
|
2392
|
+
|
|
2393
|
+
for low-level primitives.
|
|
2394
|
+
|
|
2395
|
+
**Incorrect: JS-based bottom sheet**
|
|
2396
|
+
|
|
2397
|
+
```tsx
|
|
2398
|
+
import BottomSheet from 'custom-js-bottom-sheet'
|
|
2399
|
+
|
|
2400
|
+
function MyScreen() {
|
|
2401
|
+
const sheetRef = useRef<BottomSheet>(null)
|
|
2402
|
+
|
|
2403
|
+
return (
|
|
2404
|
+
<View style={{ flex: 1 }}>
|
|
2405
|
+
<Button onPress={() => sheetRef.current?.expand()} title='Open' />
|
|
2406
|
+
<BottomSheet ref={sheetRef} snapPoints={['50%', '90%']}>
|
|
2407
|
+
<View>
|
|
2408
|
+
<Text>Sheet content</Text>
|
|
2409
|
+
</View>
|
|
2410
|
+
</BottomSheet>
|
|
2411
|
+
</View>
|
|
2412
|
+
)
|
|
2413
|
+
}
|
|
2414
|
+
```
|
|
2415
|
+
|
|
2416
|
+
**Correct: native Modal with formSheet**
|
|
2417
|
+
|
|
2418
|
+
```tsx
|
|
2419
|
+
import { Modal, View, Text, Button } from 'react-native'
|
|
2420
|
+
|
|
2421
|
+
function MyScreen() {
|
|
2422
|
+
const [visible, setVisible] = useState(false)
|
|
2423
|
+
|
|
2424
|
+
return (
|
|
2425
|
+
<View style={{ flex: 1 }}>
|
|
2426
|
+
<Button onPress={() => setVisible(true)} title='Open' />
|
|
2427
|
+
<Modal
|
|
2428
|
+
visible={visible}
|
|
2429
|
+
presentationStyle='formSheet'
|
|
2430
|
+
animationType='slide'
|
|
2431
|
+
onRequestClose={() => setVisible(false)}
|
|
2432
|
+
>
|
|
2433
|
+
<View>
|
|
2434
|
+
<Text>Sheet content</Text>
|
|
2435
|
+
</View>
|
|
2436
|
+
</Modal>
|
|
2437
|
+
</View>
|
|
2438
|
+
)
|
|
2439
|
+
}
|
|
2440
|
+
```
|
|
2441
|
+
|
|
2442
|
+
**Correct: React Navigation v7 native form sheet**
|
|
2443
|
+
|
|
2444
|
+
```tsx
|
|
2445
|
+
// In your navigator
|
|
2446
|
+
<Stack.Screen
|
|
2447
|
+
name='Details'
|
|
2448
|
+
component={DetailsScreen}
|
|
2449
|
+
options={{
|
|
2450
|
+
presentation: 'formSheet',
|
|
2451
|
+
sheetAllowedDetents: 'fitToContents',
|
|
2452
|
+
}}
|
|
2453
|
+
/>
|
|
2454
|
+
```
|
|
2455
|
+
|
|
2456
|
+
Native modals provide swipe-to-dismiss, proper keyboard avoidance, and
|
|
2457
|
+
|
|
2458
|
+
accessibility out of the box.
|
|
2459
|
+
|
|
2460
|
+
### 9.9 Use Pressable Instead of Touchable Components
|
|
2461
|
+
|
|
2462
|
+
**Impact: LOW (modern API, more flexible)**
|
|
2463
|
+
|
|
2464
|
+
Never use `TouchableOpacity` or `TouchableHighlight`. Use `Pressable` from
|
|
2465
|
+
|
|
2466
|
+
`react-native` or `react-native-gesture-handler` instead.
|
|
2467
|
+
|
|
2468
|
+
**Incorrect: legacy Touchable components**
|
|
2469
|
+
|
|
2470
|
+
```tsx
|
|
2471
|
+
import { TouchableOpacity } from 'react-native'
|
|
2472
|
+
|
|
2473
|
+
function MyButton({ onPress }: { onPress: () => void }) {
|
|
2474
|
+
return (
|
|
2475
|
+
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
|
2476
|
+
<Text>Press me</Text>
|
|
2477
|
+
</TouchableOpacity>
|
|
2478
|
+
)
|
|
2479
|
+
}
|
|
2480
|
+
```
|
|
2481
|
+
|
|
2482
|
+
**Correct: Pressable**
|
|
2483
|
+
|
|
2484
|
+
```tsx
|
|
2485
|
+
import { Pressable } from 'react-native'
|
|
2486
|
+
|
|
2487
|
+
function MyButton({ onPress }: { onPress: () => void }) {
|
|
2488
|
+
return (
|
|
2489
|
+
<Pressable onPress={onPress}>
|
|
2490
|
+
<Text>Press me</Text>
|
|
2491
|
+
</Pressable>
|
|
2492
|
+
)
|
|
2493
|
+
}
|
|
2494
|
+
```
|
|
2495
|
+
|
|
2496
|
+
**Correct: Pressable from gesture handler for lists**
|
|
2497
|
+
|
|
2498
|
+
```tsx
|
|
2499
|
+
import { Pressable } from 'react-native-gesture-handler'
|
|
2500
|
+
|
|
2501
|
+
function ListItem({ onPress }: { onPress: () => void }) {
|
|
2502
|
+
return (
|
|
2503
|
+
<Pressable onPress={onPress}>
|
|
2504
|
+
<Text>Item</Text>
|
|
2505
|
+
</Pressable>
|
|
2506
|
+
)
|
|
2507
|
+
}
|
|
2508
|
+
```
|
|
2509
|
+
|
|
2510
|
+
Use `react-native-gesture-handler` Pressable inside scrollable lists for better
|
|
2511
|
+
|
|
2512
|
+
gesture coordination, as long as you are using the ScrollView from
|
|
2513
|
+
|
|
2514
|
+
`react-native-gesture-handler` as well.
|
|
2515
|
+
|
|
2516
|
+
**For animated press states (scale, opacity changes):** Use `GestureDetector`
|
|
2517
|
+
|
|
2518
|
+
with Reanimated shared values instead of Pressable's style callback. See the
|
|
2519
|
+
|
|
2520
|
+
`animation-gesture-detector-press` rule.
|
|
2521
|
+
|
|
2522
|
+
---
|
|
2523
|
+
|
|
2524
|
+
## 10. Design System
|
|
2525
|
+
|
|
2526
|
+
**Impact: MEDIUM**
|
|
2527
|
+
|
|
2528
|
+
Architecture patterns for building maintainable component
|
|
2529
|
+
libraries.
|
|
2530
|
+
|
|
2531
|
+
### 10.1 Use Compound Components Over Polymorphic Children
|
|
2532
|
+
|
|
2533
|
+
**Impact: MEDIUM (flexible composition, clearer API)**
|
|
2534
|
+
|
|
2535
|
+
Don't create components that can accept a string if they aren't a text node. If
|
|
2536
|
+
|
|
2537
|
+
a component can receive a string child, it must be a dedicated `*Text`
|
|
2538
|
+
|
|
2539
|
+
component. For components like buttons, which can have both a View (or
|
|
2540
|
+
|
|
2541
|
+
Pressable) together with text, use compound components, such a `Button`,
|
|
2542
|
+
|
|
2543
|
+
`ButtonText`, and `ButtonIcon`.
|
|
2544
|
+
|
|
2545
|
+
**Incorrect: polymorphic children**
|
|
2546
|
+
|
|
2547
|
+
```tsx
|
|
2548
|
+
import { Pressable, Text } from 'react-native'
|
|
2549
|
+
|
|
2550
|
+
type ButtonProps = {
|
|
2551
|
+
children: string | React.ReactNode
|
|
2552
|
+
icon?: React.ReactNode
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
function Button({ children, icon }: ButtonProps) {
|
|
2556
|
+
return (
|
|
2557
|
+
<Pressable>
|
|
2558
|
+
{icon}
|
|
2559
|
+
{typeof children === 'string' ? <Text>{children}</Text> : children}
|
|
2560
|
+
</Pressable>
|
|
2561
|
+
)
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// Usage is ambiguous
|
|
2565
|
+
<Button icon={<Icon />}>Save</Button>
|
|
2566
|
+
<Button><CustomText>Save</CustomText></Button>
|
|
2567
|
+
```
|
|
2568
|
+
|
|
2569
|
+
**Correct: compound components**
|
|
2570
|
+
|
|
2571
|
+
```tsx
|
|
2572
|
+
import { Pressable, Text } from 'react-native'
|
|
2573
|
+
|
|
2574
|
+
function Button({ children }: { children: React.ReactNode }) {
|
|
2575
|
+
return <Pressable>{children}</Pressable>
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function ButtonText({ children }: { children: React.ReactNode }) {
|
|
2579
|
+
return <Text>{children}</Text>
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function ButtonIcon({ children }: { children: React.ReactNode }) {
|
|
2583
|
+
return <>{children}</>
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// Usage is explicit and composable
|
|
2587
|
+
<Button>
|
|
2588
|
+
<ButtonIcon><SaveIcon /></ButtonIcon>
|
|
2589
|
+
<ButtonText>Save</ButtonText>
|
|
2590
|
+
</Button>
|
|
2591
|
+
|
|
2592
|
+
<Button>
|
|
2593
|
+
<ButtonText>Cancel</ButtonText>
|
|
2594
|
+
</Button>
|
|
2595
|
+
```
|
|
2596
|
+
|
|
2597
|
+
---
|
|
2598
|
+
|
|
2599
|
+
## 11. Monorepo
|
|
2600
|
+
|
|
2601
|
+
**Impact: LOW**
|
|
2602
|
+
|
|
2603
|
+
Dependency management and native module configuration in
|
|
2604
|
+
monorepos.
|
|
2605
|
+
|
|
2606
|
+
### 11.1 Install Native Dependencies in App Directory
|
|
2607
|
+
|
|
2608
|
+
**Impact: CRITICAL (required for autolinking to work)**
|
|
2609
|
+
|
|
2610
|
+
In a monorepo, packages with native code must be installed in the native app's
|
|
2611
|
+
|
|
2612
|
+
directory directly. Autolinking only scans the app's `node_modules`—it won't
|
|
2613
|
+
|
|
2614
|
+
find native dependencies installed in other packages.
|
|
2615
|
+
|
|
2616
|
+
**Incorrect: native dep in shared package only**
|
|
2617
|
+
|
|
2618
|
+
```typescript
|
|
2619
|
+
packages/
|
|
2620
|
+
ui/
|
|
2621
|
+
package.json # has react-native-reanimated
|
|
2622
|
+
app/
|
|
2623
|
+
package.json # missing react-native-reanimated
|
|
2624
|
+
```
|
|
2625
|
+
|
|
2626
|
+
Autolinking fails—native code not linked.
|
|
2627
|
+
|
|
2628
|
+
**Correct: native dep in app directory**
|
|
2629
|
+
|
|
2630
|
+
```json
|
|
2631
|
+
// packages/app/package.json
|
|
2632
|
+
{
|
|
2633
|
+
"dependencies": {
|
|
2634
|
+
"react-native-reanimated": "3.16.1"
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
```
|
|
2638
|
+
|
|
2639
|
+
Even if the shared package uses the native dependency, the app must also list it
|
|
2640
|
+
|
|
2641
|
+
for autolinking to detect and link the native code.
|
|
2642
|
+
|
|
2643
|
+
### 11.2 Use Single Dependency Versions Across Monorepo
|
|
2644
|
+
|
|
2645
|
+
**Impact: MEDIUM (avoids duplicate bundles, version conflicts)**
|
|
2646
|
+
|
|
2647
|
+
Use a single version of each dependency across all packages in your monorepo.
|
|
2648
|
+
|
|
2649
|
+
Prefer exact versions over ranges. Multiple versions cause duplicate code in
|
|
2650
|
+
|
|
2651
|
+
bundles, runtime conflicts, and inconsistent behavior across packages.
|
|
2652
|
+
|
|
2653
|
+
Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions
|
|
2654
|
+
|
|
2655
|
+
or npm overrides.
|
|
2656
|
+
|
|
2657
|
+
**Incorrect: version ranges, multiple versions**
|
|
2658
|
+
|
|
2659
|
+
```json
|
|
2660
|
+
// packages/app/package.json
|
|
2661
|
+
{
|
|
2662
|
+
"dependencies": {
|
|
2663
|
+
"react-native-reanimated": "^3.0.0"
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// packages/ui/package.json
|
|
2668
|
+
{
|
|
2669
|
+
"dependencies": {
|
|
2670
|
+
"react-native-reanimated": "^3.5.0"
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
```
|
|
2674
|
+
|
|
2675
|
+
**Correct: exact versions, single source of truth**
|
|
2676
|
+
|
|
2677
|
+
```json
|
|
2678
|
+
// package.json (root)
|
|
2679
|
+
{
|
|
2680
|
+
"pnpm": {
|
|
2681
|
+
"overrides": {
|
|
2682
|
+
"react-native-reanimated": "3.16.1"
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// packages/app/package.json
|
|
2688
|
+
{
|
|
2689
|
+
"dependencies": {
|
|
2690
|
+
"react-native-reanimated": "3.16.1"
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
// packages/ui/package.json
|
|
2695
|
+
{
|
|
2696
|
+
"dependencies": {
|
|
2697
|
+
"react-native-reanimated": "3.16.1"
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
```
|
|
2701
|
+
|
|
2702
|
+
Use your package manager's override/resolution feature to enforce versions at
|
|
2703
|
+
|
|
2704
|
+
the root. When adding dependencies, specify exact versions without `^` or `~`.
|
|
2705
|
+
|
|
2706
|
+
---
|
|
2707
|
+
|
|
2708
|
+
## 12. Third-Party Dependencies
|
|
2709
|
+
|
|
2710
|
+
**Impact: LOW**
|
|
2711
|
+
|
|
2712
|
+
Wrapping and re-exporting third-party dependencies for
|
|
2713
|
+
maintainability.
|
|
2714
|
+
|
|
2715
|
+
### 12.1 Import from Design System Folder
|
|
2716
|
+
|
|
2717
|
+
**Impact: LOW (enables global changes and easy refactoring)**
|
|
2718
|
+
|
|
2719
|
+
Re-export dependencies from a design system folder. App code imports from there,
|
|
2720
|
+
|
|
2721
|
+
not directly from packages. This enables global changes and easy refactoring.
|
|
2722
|
+
|
|
2723
|
+
**Incorrect: imports directly from package**
|
|
2724
|
+
|
|
2725
|
+
```tsx
|
|
2726
|
+
import { View, Text } from 'react-native'
|
|
2727
|
+
import { Button } from '@ui/button'
|
|
2728
|
+
|
|
2729
|
+
function Profile() {
|
|
2730
|
+
return (
|
|
2731
|
+
<View>
|
|
2732
|
+
<Text>Hello</Text>
|
|
2733
|
+
<Button>Save</Button>
|
|
2734
|
+
</View>
|
|
2735
|
+
)
|
|
2736
|
+
}
|
|
2737
|
+
```
|
|
2738
|
+
|
|
2739
|
+
**Correct: imports from design system**
|
|
2740
|
+
|
|
2741
|
+
```tsx
|
|
2742
|
+
import { View } from '@/components/view'
|
|
2743
|
+
import { Text } from '@/components/text'
|
|
2744
|
+
import { Button } from '@/components/button'
|
|
2745
|
+
|
|
2746
|
+
function Profile() {
|
|
2747
|
+
return (
|
|
2748
|
+
<View>
|
|
2749
|
+
<Text>Hello</Text>
|
|
2750
|
+
<Button>Save</Button>
|
|
2751
|
+
</View>
|
|
2752
|
+
)
|
|
2753
|
+
}
|
|
2754
|
+
```
|
|
2755
|
+
|
|
2756
|
+
Start by simply re-exporting. Customize later without changing app code.
|
|
2757
|
+
|
|
2758
|
+
---
|
|
2759
|
+
|
|
2760
|
+
## 13. JavaScript
|
|
2761
|
+
|
|
2762
|
+
**Impact: LOW**
|
|
2763
|
+
|
|
2764
|
+
Micro-optimizations like hoisting expensive object creation.
|
|
2765
|
+
|
|
2766
|
+
### 13.1 Hoist Intl Formatter Creation
|
|
2767
|
+
|
|
2768
|
+
**Impact: LOW-MEDIUM (avoids expensive object recreation)**
|
|
2769
|
+
|
|
2770
|
+
Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or
|
|
2771
|
+
|
|
2772
|
+
`Intl.RelativeTimeFormat` inside render or loops. These are expensive to
|
|
2773
|
+
|
|
2774
|
+
instantiate. Hoist to module scope when the locale/options are static.
|
|
2775
|
+
|
|
2776
|
+
**Incorrect: new formatter every render**
|
|
2777
|
+
|
|
2778
|
+
```tsx
|
|
2779
|
+
function Price({ amount }: { amount: number }) {
|
|
2780
|
+
const formatter = new Intl.NumberFormat('en-US', {
|
|
2781
|
+
style: 'currency',
|
|
2782
|
+
currency: 'USD',
|
|
2783
|
+
})
|
|
2784
|
+
return <Text>{formatter.format(amount)}</Text>
|
|
2785
|
+
}
|
|
2786
|
+
```
|
|
2787
|
+
|
|
2788
|
+
**Correct: hoisted to module scope**
|
|
2789
|
+
|
|
2790
|
+
```tsx
|
|
2791
|
+
const currencyFormatter = new Intl.NumberFormat('en-US', {
|
|
2792
|
+
style: 'currency',
|
|
2793
|
+
currency: 'USD',
|
|
2794
|
+
})
|
|
2795
|
+
|
|
2796
|
+
function Price({ amount }: { amount: number }) {
|
|
2797
|
+
return <Text>{currencyFormatter.format(amount)}</Text>
|
|
2798
|
+
}
|
|
2799
|
+
```
|
|
2800
|
+
|
|
2801
|
+
**For dynamic locales, memoize:**
|
|
2802
|
+
|
|
2803
|
+
```tsx
|
|
2804
|
+
const dateFormatter = useMemo(
|
|
2805
|
+
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }),
|
|
2806
|
+
[locale]
|
|
2807
|
+
)
|
|
2808
|
+
```
|
|
2809
|
+
|
|
2810
|
+
**Common formatters to hoist:**
|
|
2811
|
+
|
|
2812
|
+
```tsx
|
|
2813
|
+
// Module-level formatters
|
|
2814
|
+
const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })
|
|
2815
|
+
const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' })
|
|
2816
|
+
const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' })
|
|
2817
|
+
const relativeFormatter = new Intl.RelativeTimeFormat('en-US', {
|
|
2818
|
+
numeric: 'auto',
|
|
2819
|
+
})
|
|
2820
|
+
```
|
|
2821
|
+
|
|
2822
|
+
Creating `Intl` objects is significantly more expensive than `RegExp` or plain
|
|
2823
|
+
|
|
2824
|
+
objects—each instantiation parses locale data and builds internal lookup tables.
|
|
2825
|
+
|
|
2826
|
+
---
|
|
2827
|
+
|
|
2828
|
+
## 14. Fonts
|
|
2829
|
+
|
|
2830
|
+
**Impact: LOW**
|
|
2831
|
+
|
|
2832
|
+
Native font loading for improved performance.
|
|
2833
|
+
|
|
2834
|
+
### 14.1 Load fonts natively at build time
|
|
2835
|
+
|
|
2836
|
+
**Impact: LOW (fonts available at launch, no async loading)**
|
|
2837
|
+
|
|
2838
|
+
Use the `expo-font` config plugin to embed fonts at build time instead of
|
|
2839
|
+
|
|
2840
|
+
`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient.
|
|
2841
|
+
|
|
2842
|
+
[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/)
|
|
2843
|
+
|
|
2844
|
+
**Incorrect: async font loading**
|
|
2845
|
+
|
|
2846
|
+
```tsx
|
|
2847
|
+
import { useFonts } from 'expo-font'
|
|
2848
|
+
import { Text, View } from 'react-native'
|
|
2849
|
+
|
|
2850
|
+
function App() {
|
|
2851
|
+
const [fontsLoaded] = useFonts({
|
|
2852
|
+
'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'),
|
|
2853
|
+
})
|
|
2854
|
+
|
|
2855
|
+
if (!fontsLoaded) {
|
|
2856
|
+
return null
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
return (
|
|
2860
|
+
<View>
|
|
2861
|
+
<Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
|
|
2862
|
+
</View>
|
|
2863
|
+
)
|
|
2864
|
+
}
|
|
2865
|
+
```
|
|
2866
|
+
|
|
2867
|
+
**Correct: config plugin, fonts embedded at build**
|
|
2868
|
+
|
|
2869
|
+
```tsx
|
|
2870
|
+
import { Text, View } from 'react-native'
|
|
2871
|
+
|
|
2872
|
+
function App() {
|
|
2873
|
+
// No loading state needed—font is already available
|
|
2874
|
+
return (
|
|
2875
|
+
<View>
|
|
2876
|
+
<Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
|
|
2877
|
+
</View>
|
|
2878
|
+
)
|
|
2879
|
+
}
|
|
2880
|
+
```
|
|
2881
|
+
|
|
2882
|
+
After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the
|
|
2883
|
+
|
|
2884
|
+
native app.
|
|
2885
|
+
|
|
2886
|
+
---
|
|
2887
|
+
|
|
2888
|
+
## References
|
|
2889
|
+
|
|
2890
|
+
1. [https://react.dev](https://react.dev)
|
|
2891
|
+
2. [https://reactnative.dev](https://reactnative.dev)
|
|
2892
|
+
3. [https://docs.swmansion.com/react-native-reanimated](https://docs.swmansion.com/react-native-reanimated)
|
|
2893
|
+
4. [https://docs.swmansion.com/react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler)
|
|
2894
|
+
5. [https://docs.expo.dev](https://docs.expo.dev)
|
|
2895
|
+
6. [https://legendapp.com/open-source/legend-list](https://legendapp.com/open-source/legend-list)
|
|
2896
|
+
7. [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria)
|
|
2897
|
+
8. [https://zeego.dev](https://zeego.dev)
|