format: apply repo prettier (3.8.1) to the folded skills tree
963 markdown files reformatted with the repository's pinned prettier so pnpm format:check covers the folded tree like every other repo file. The formatter's embedded-language pass also normalized code fences (TS semicolons, closed HTML tags in examples, lowercased CSS hex colors, one renumbered list that skipped an index). Alphanumeric token deltas vs the fold commit were audited file-by-file; all are formatter-equivalent markup normalizations plus the four sanitized skills.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: vercel-react-native-skills
|
||||
description:
|
||||
React Native and Expo best practices for building performant mobile apps. Use
|
||||
description: React Native and Expo best practices for building performant mobile apps. Use
|
||||
when building React Native components, optimizing list performance,
|
||||
implementing animations, or working with native modules. Triggers on tasks
|
||||
involving React Native, Expo, mobile performance, or native platform APIs.
|
||||
|
||||
@@ -15,14 +15,14 @@ Brief explanation of the rule and why it matters. This should be clear and conci
|
||||
|
||||
```typescript
|
||||
// Bad code example here
|
||||
const bad = example()
|
||||
const bad = example();
|
||||
```
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```typescript
|
||||
// Good code example here
|
||||
const good = example()
|
||||
const good = example();
|
||||
```
|
||||
|
||||
Reference: [Link to documentation or resource](https://example.com)
|
||||
|
||||
+9
-9
@@ -15,18 +15,18 @@ for side effects, not derivations.
|
||||
**Incorrect (useAnimatedReaction for derivation):**
|
||||
|
||||
```tsx
|
||||
import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated'
|
||||
import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated';
|
||||
|
||||
function MyComponent() {
|
||||
const progress = useSharedValue(0)
|
||||
const opacity = useSharedValue(1)
|
||||
const progress = useSharedValue(0);
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => progress.value,
|
||||
(current) => {
|
||||
opacity.value = 1 - current
|
||||
}
|
||||
)
|
||||
opacity.value = 1 - current;
|
||||
},
|
||||
);
|
||||
|
||||
// ...
|
||||
}
|
||||
@@ -35,12 +35,12 @@ function MyComponent() {
|
||||
**Correct (useDerivedValue):**
|
||||
|
||||
```tsx
|
||||
import { useSharedValue, useDerivedValue } from 'react-native-reanimated'
|
||||
import { useSharedValue, useDerivedValue } from 'react-native-reanimated';
|
||||
|
||||
function MyComponent() {
|
||||
const progress = useSharedValue(0)
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
const opacity = useDerivedValue(() => 1 - progress.get())
|
||||
const opacity = useDerivedValue(() => 1 - progress.get());
|
||||
|
||||
// ...
|
||||
}
|
||||
|
||||
+15
-21
@@ -15,19 +15,15 @@ JS thread round-trip for press animations.
|
||||
**Incorrect (Pressable with JS thread callbacks):**
|
||||
|
||||
```tsx
|
||||
import { Pressable } from 'react-native'
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import { Pressable } from 'react-native';
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||
|
||||
function AnimatedButton({ onPress }: { onPress: () => void }) {
|
||||
const scale = useSharedValue(1)
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}))
|
||||
}));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -39,43 +35,41 @@ function AnimatedButton({ onPress }: { onPress: () => void }) {
|
||||
<Text>Press me</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (GestureDetector with UI thread worklets):**
|
||||
|
||||
```tsx
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated'
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
function AnimatedButton({ onPress }: { onPress: () => void }) {
|
||||
// Store the press STATE (0 = not pressed, 1 = pressed)
|
||||
const pressed = useSharedValue(0)
|
||||
const pressed = useSharedValue(0);
|
||||
|
||||
const tap = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withTiming(1))
|
||||
pressed.set(withTiming(1));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withTiming(0))
|
||||
pressed.set(withTiming(0));
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(onPress)()
|
||||
})
|
||||
runOnJS(onPress)();
|
||||
});
|
||||
|
||||
// Derive visual values from the state
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) },
|
||||
],
|
||||
}))
|
||||
transform: [{ scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={tap}>
|
||||
@@ -83,7 +77,7 @@ function AnimatedButton({ onPress }: { onPress: () => void }) {
|
||||
<Text>Press me</Text>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+11
-15
@@ -12,53 +12,49 @@ Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These
|
||||
**Incorrect (animates height, triggers layout every frame):**
|
||||
|
||||
```tsx
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||
|
||||
function CollapsiblePanel({ expanded }: { expanded: boolean }) {
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
height: withTiming(expanded ? 200 : 0), // triggers layout on every frame
|
||||
overflow: 'hidden',
|
||||
}))
|
||||
}));
|
||||
|
||||
return <Animated.View style={animatedStyle}>{children}</Animated.View>
|
||||
return <Animated.View style={animatedStyle}>{children}</Animated.View>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (animates scaleY, GPU-accelerated):**
|
||||
|
||||
```tsx
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||
|
||||
function CollapsiblePanel({ expanded }: { expanded: boolean }) {
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ scaleY: withTiming(expanded ? 1 : 0) },
|
||||
],
|
||||
transform: [{ scaleY: withTiming(expanded ? 1 : 0) }],
|
||||
opacity: withTiming(expanded ? 1 : 0),
|
||||
}))
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[{ height: 200, transformOrigin: 'top' }, animatedStyle]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (animates translateY for slide animations):**
|
||||
|
||||
```tsx
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
|
||||
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||
|
||||
function SlideIn({ visible }: { visible: boolean }) {
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateY: withTiming(visible ? 0 : 100) },
|
||||
],
|
||||
transform: [{ translateY: withTiming(visible ? 0 : 100) }],
|
||||
opacity: withTiming(visible ? 1 : 0),
|
||||
}))
|
||||
}));
|
||||
|
||||
return <Animated.View style={animatedStyle}>{children}</Animated.View>
|
||||
return <Animated.View style={animatedStyle}>{children}</Animated.View>;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+7
-7
@@ -13,23 +13,23 @@ Use the `expo-font` config plugin to embed fonts at build time instead of
|
||||
**Incorrect (async font loading):**
|
||||
|
||||
```tsx
|
||||
import { useFonts } from 'expo-font'
|
||||
import { Text, View } from 'react-native'
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
function App() {
|
||||
const [fontsLoaded] = useFonts({
|
||||
'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'),
|
||||
})
|
||||
});
|
||||
|
||||
if (!fontsLoaded) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -52,7 +52,7 @@ function App() {
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Text, View } from 'react-native'
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
function App() {
|
||||
// No loading state needed—font is already available
|
||||
@@ -60,7 +60,7 @@ function App() {
|
||||
<View>
|
||||
<Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+12
-14
@@ -13,8 +13,8 @@ not directly from packages. This enables global changes and easy refactoring.
|
||||
**Incorrect (imports directly from package):**
|
||||
|
||||
```tsx
|
||||
import { View, Text } from 'react-native'
|
||||
import { Button } from '@ui/button'
|
||||
import { View, Text } from 'react-native';
|
||||
import { Button } from '@ui/button';
|
||||
|
||||
function Profile() {
|
||||
return (
|
||||
@@ -22,7 +22,7 @@ function Profile() {
|
||||
<Text>Hello</Text>
|
||||
<Button>Save</Button>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -30,30 +30,28 @@ function Profile() {
|
||||
|
||||
```tsx
|
||||
// components/view.tsx
|
||||
import { View as RNView } from 'react-native'
|
||||
import { View as RNView } from 'react-native';
|
||||
|
||||
// ideal: pick the props you will actually use to control implementation
|
||||
export function View(
|
||||
props: Pick<React.ComponentProps<typeof RNView>, 'style' | 'children'>
|
||||
) {
|
||||
return <RNView {...props} />
|
||||
export function View(props: Pick<React.ComponentProps<typeof RNView>, 'style' | 'children'>) {
|
||||
return <RNView {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/text.tsx
|
||||
export { Text } from 'react-native'
|
||||
export { Text } from 'react-native';
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/button.tsx
|
||||
export { Button } from '@ui/button'
|
||||
export { Button } from '@ui/button';
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { View } from '@/components/view'
|
||||
import { Text } from '@/components/text'
|
||||
import { Button } from '@/components/button'
|
||||
import { View } from '@/components/view';
|
||||
import { Text } from '@/components/text';
|
||||
import { Button } from '@/components/button';
|
||||
|
||||
function Profile() {
|
||||
return (
|
||||
@@ -61,7 +59,7 @@ function Profile() {
|
||||
<Text>Hello</Text>
|
||||
<Button>Save</Button>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+10
-10
@@ -18,8 +18,8 @@ function Price({ amount }: { amount: number }) {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
return <Text>{formatter.format(amount)}</Text>
|
||||
});
|
||||
return <Text>{formatter.format(amount)}</Text>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -29,10 +29,10 @@ function Price({ amount }: { amount: number }) {
|
||||
const currencyFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
})
|
||||
});
|
||||
|
||||
function Price({ amount }: { amount: number }) {
|
||||
return <Text>{currencyFormatter.format(amount)}</Text>
|
||||
return <Text>{currencyFormatter.format(amount)}</Text>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,20 +41,20 @@ function Price({ amount }: { amount: number }) {
|
||||
```tsx
|
||||
const dateFormatter = useMemo(
|
||||
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }),
|
||||
[locale]
|
||||
)
|
||||
[locale],
|
||||
);
|
||||
```
|
||||
|
||||
**Common formatters to hoist:**
|
||||
|
||||
```tsx
|
||||
// Module-level formatters
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' })
|
||||
const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' })
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' });
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' });
|
||||
const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' });
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat('en-US', {
|
||||
numeric: 'auto',
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
Creating `Intl` objects is significantly more expensive than `RegExp` or plain
|
||||
|
||||
+19
-19
@@ -18,15 +18,15 @@ Where needed, use context selectors within list items.
|
||||
|
||||
```tsx
|
||||
function DomainSearch() {
|
||||
const { keyword, setKeyword } = useKeywordZustandState()
|
||||
const { data: tlds } = useTlds()
|
||||
const { keyword, setKeyword } = useKeywordZustandState();
|
||||
const { data: tlds } = useTlds();
|
||||
|
||||
// Bad: creates new objects on every render, reparenting the entire list on every keystroke
|
||||
const domains = tlds.map((tld) => ({
|
||||
domain: `${keyword}.${tld.name}`,
|
||||
tld: tld.name,
|
||||
price: tld.price,
|
||||
}))
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,17 +36,17 @@ function DomainSearch() {
|
||||
renderItem={({ item }) => <DomainItem item={item} keyword={keyword} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (stable references, transform inside items):**
|
||||
|
||||
```tsx
|
||||
const renderItem = ({ item }) => <DomainItem tld={item} />
|
||||
const renderItem = ({ item }) => <DomainItem tld={item} />;
|
||||
|
||||
function DomainSearch() {
|
||||
const { data: tlds } = useTlds()
|
||||
const { data: tlds } = useTlds();
|
||||
|
||||
return (
|
||||
<LegendList
|
||||
@@ -54,14 +54,14 @@ function DomainSearch() {
|
||||
data={tlds}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DomainItem({ tld }: { tld: Tld }) {
|
||||
// good: transform within items, and don't pass the dynamic data as a prop
|
||||
// good: use a selector function from zustand to receive a stable string back
|
||||
const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name)
|
||||
return <Text>{domain}</Text>
|
||||
const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name);
|
||||
return <Text>{domain}</Text>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -73,9 +73,9 @@ references are stable. For instance, if you sort a list of objects:
|
||||
```tsx
|
||||
// good: creates a new array instance without mutating the inner objects
|
||||
// good: parent array reference is unaffected by typing and updating "keyword"
|
||||
const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return <LegendList data={sortedTlds} renderItem={renderItem} />
|
||||
return <LegendList data={sortedTlds} renderItem={renderItem} />;
|
||||
```
|
||||
|
||||
Even though this creates a new array instance `sortedTlds`, the inner object
|
||||
@@ -84,10 +84,10 @@ references are stable.
|
||||
**With zustand for dynamic data (avoids parent re-renders):**
|
||||
|
||||
```tsx
|
||||
const useSearchStore = create<{ keyword: string }>(() => ({ keyword: '' }))
|
||||
const useSearchStore = create<{ keyword: string }>(() => ({ keyword: '' }));
|
||||
|
||||
function DomainSearch() {
|
||||
const { data: tlds } = useTlds()
|
||||
const { data: tlds } = useTlds();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -98,14 +98,14 @@ function DomainSearch() {
|
||||
renderItem={({ item }) => <DomainItem tld={item} />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DomainItem({ tld }: { tld: Tld }) {
|
||||
// Select only what you need—component only re-renders when keyword changes
|
||||
const keyword = useSearchStore((s) => s.keyword)
|
||||
const domain = `${keyword}.${tld.name}`
|
||||
return <Text>{domain}</Text>
|
||||
const keyword = useSearchStore((s) => s.keyword);
|
||||
const domain = `${keyword}.${tld.name}`;
|
||||
return <Text>{domain}</Text>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -122,8 +122,8 @@ is in charge of accessing the state rather than the parent:
|
||||
|
||||
```tsx
|
||||
function DomainItemFavoriteButton({ tld }: { tld: Tld }) {
|
||||
const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id))
|
||||
return <TldFavoriteButton isFavorited={isFavorited} />
|
||||
const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id));
|
||||
return <TldFavoriteButton isFavorited={isFavorited} />;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+5
-8
@@ -18,13 +18,10 @@ function ProductItem({ product }: { product: Product }) {
|
||||
return (
|
||||
<View>
|
||||
{/* 4000x3000 image loaded for a 100x100 thumbnail */}
|
||||
<Image
|
||||
source={{ uri: product.imageUrl }}
|
||||
style={{ width: 100, height: 100 }}
|
||||
/>
|
||||
<Image source={{ uri: product.imageUrl }} style={{ width: 100, height: 100 }} />
|
||||
<Text>{product.name}</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -33,18 +30,18 @@ function ProductItem({ product }: { product: Product }) {
|
||||
```tsx
|
||||
function ProductItem({ product }: { product: Product }) {
|
||||
// Request a 200x200 image (2x for retina)
|
||||
const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`
|
||||
const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Image
|
||||
source={{ uri: thumbnailUrl }}
|
||||
style={{ width: 100, height: 100 }}
|
||||
contentFit='cover'
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Text>{product.name}</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ function UserList({ users }: { users: User[] }) {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -53,7 +53,7 @@ function UserList({ users }: { users: User[] }) {
|
||||
<UserRow user={item} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+12
-15
@@ -16,18 +16,15 @@ during scroll—expensive items cause jank.
|
||||
```tsx
|
||||
function ProductRow({ id }: { id: string }) {
|
||||
// Bad: query inside list item
|
||||
const { data: product } = useQuery(['product', id], () => fetchProduct(id))
|
||||
const { data: product } = useQuery(['product', id], () => fetchProduct(id));
|
||||
// Bad: multiple context accesses
|
||||
const theme = useContext(ThemeContext)
|
||||
const user = useContext(UserContext)
|
||||
const cart = useContext(CartContext)
|
||||
const theme = useContext(ThemeContext);
|
||||
const user = useContext(UserContext);
|
||||
const cart = useContext(CartContext);
|
||||
// Bad: expensive computation
|
||||
const recommendations = useMemo(
|
||||
() => computeRecommendations(product),
|
||||
[product]
|
||||
)
|
||||
const recommendations = useMemo(() => computeRecommendations(product), [product]);
|
||||
|
||||
return <View>{/* ... */}</View>
|
||||
return <View>{/* ... */}</View>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -42,7 +39,7 @@ function ProductRow({ name, price, imageUrl }: Props) {
|
||||
<Text>{name}</Text>
|
||||
<Text>{price}</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -51,7 +48,7 @@ function ProductRow({ name, price, imageUrl }: Props) {
|
||||
```tsx
|
||||
// Parent fetches all data once
|
||||
function ProductList() {
|
||||
const { data: products } = useQuery(['products'], fetchProducts)
|
||||
const { data: products } = useQuery(['products'], fetchProducts);
|
||||
|
||||
return (
|
||||
<LegendList
|
||||
@@ -60,7 +57,7 @@ function ProductList() {
|
||||
<ProductRow name={item.name} price={item.price} imageUrl={item.image} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,15 +66,15 @@ function ProductList() {
|
||||
```tsx
|
||||
// Incorrect: Context causes re-render when any cart value changes
|
||||
function ProductRow({ id, name }: Props) {
|
||||
const { items } = useContext(CartContext)
|
||||
const inCart = items.includes(id)
|
||||
const { items } = useContext(CartContext);
|
||||
const inCart = items.includes(id);
|
||||
// ...
|
||||
}
|
||||
|
||||
// Correct: Zustand selector only re-renders when this specific value changes
|
||||
function ProductRow({ id, name }: Props) {
|
||||
// use Set.has (created once at the root) instead of Array.includes()
|
||||
const inCart = useCartStore((s) => s.items.has(id))
|
||||
const inCart = useCartStore((s) => s.items.has(id));
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
+18
-22
@@ -15,36 +15,32 @@ image component.
|
||||
**Incorrect (single component with conditionals):**
|
||||
|
||||
```tsx
|
||||
type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean }
|
||||
type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean };
|
||||
|
||||
function ListItem({ item }: { item: Item }) {
|
||||
if (item.isHeader) {
|
||||
return <HeaderItem title={item.text} />
|
||||
return <HeaderItem title={item.text} />;
|
||||
}
|
||||
if (item.imageUrl) {
|
||||
return <ImageItem url={item.imageUrl} />
|
||||
return <ImageItem url={item.imageUrl} />;
|
||||
}
|
||||
return <MessageItem text={item.text} />
|
||||
return <MessageItem text={item.text} />;
|
||||
}
|
||||
|
||||
function Feed({ items }: { items: Item[] }) {
|
||||
return (
|
||||
<LegendList
|
||||
data={items}
|
||||
renderItem={({ item }) => <ListItem item={item} />}
|
||||
recycleItems
|
||||
/>
|
||||
)
|
||||
<LegendList data={items} renderItem={({ item }) => <ListItem item={item} />} recycleItems />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (typed items with separate components):**
|
||||
|
||||
```tsx
|
||||
type HeaderItem = { id: string; type: 'header'; title: string }
|
||||
type MessageItem = { id: string; type: 'message'; text: string }
|
||||
type ImageItem = { id: string; type: 'image'; url: string }
|
||||
type FeedItem = HeaderItem | MessageItem | ImageItem
|
||||
type HeaderItem = { id: string; type: 'header'; title: string };
|
||||
type MessageItem = { id: string; type: 'message'; text: string };
|
||||
type ImageItem = { id: string; type: 'image'; url: string };
|
||||
type FeedItem = HeaderItem | MessageItem | ImageItem;
|
||||
|
||||
function Feed({ items }: { items: FeedItem[] }) {
|
||||
return (
|
||||
@@ -55,16 +51,16 @@ function Feed({ items }: { items: FeedItem[] }) {
|
||||
renderItem={({ item }) => {
|
||||
switch (item.type) {
|
||||
case 'header':
|
||||
return <SectionHeader title={item.title} />
|
||||
return <SectionHeader title={item.title} />;
|
||||
case 'message':
|
||||
return <MessageRow text={item.text} />
|
||||
return <MessageRow text={item.text} />;
|
||||
case 'image':
|
||||
return <ImageRow url={item.url} />
|
||||
return <ImageRow url={item.url} />;
|
||||
}
|
||||
}}
|
||||
recycleItems
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -84,13 +80,13 @@ function Feed({ items }: { items: FeedItem[] }) {
|
||||
getEstimatedItemSize={(index, item, itemType) => {
|
||||
switch (itemType) {
|
||||
case 'header':
|
||||
return 48
|
||||
return 48;
|
||||
case 'message':
|
||||
return 72
|
||||
return 72;
|
||||
case 'image':
|
||||
return 300
|
||||
return 300;
|
||||
default:
|
||||
return 72
|
||||
return 72;
|
||||
}
|
||||
}}
|
||||
renderItem={({ item }) => {
|
||||
|
||||
+5
-5
@@ -22,7 +22,7 @@ function Feed({ items }: { items: Item[] }) {
|
||||
<ItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
// 50 items = 50 components mounted, even if only 10 visible
|
||||
```
|
||||
@@ -30,7 +30,7 @@ function Feed({ items }: { items: Item[] }) {
|
||||
**Correct (virtualizer renders only visible items):**
|
||||
|
||||
```tsx
|
||||
import { LegendList } from '@legendapp/list'
|
||||
import { LegendList } from '@legendapp/list';
|
||||
|
||||
function Feed({ items }: { items: Item[] }) {
|
||||
return (
|
||||
@@ -41,7 +41,7 @@ function Feed({ items }: { items: Item[] }) {
|
||||
keyExtractor={(item) => item.id}
|
||||
estimatedItemSize={80}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
// Only ~10-15 visible items mounted at a time
|
||||
```
|
||||
@@ -49,7 +49,7 @@ function Feed({ items }: { items: Item[] }) {
|
||||
**Alternative (FlashList):**
|
||||
|
||||
```tsx
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
|
||||
function Feed({ items }: { items: Item[] }) {
|
||||
return (
|
||||
@@ -59,7 +59,7 @@ function Feed({ items }: { items: Item[] }) {
|
||||
renderItem={({ item }) => <ItemCard item={item} />}
|
||||
keyExtractor={(item) => item.id}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+31
-31
@@ -22,34 +22,34 @@ tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters.
|
||||
**Incorrect (JS stack navigator):**
|
||||
|
||||
```tsx
|
||||
import { createStackNavigator } from '@react-navigation/stack'
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
|
||||
const Stack = createStackNavigator()
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name='Home' component={HomeScreen} />
|
||||
<Stack.Screen name='Details' component={DetailsScreen} />
|
||||
<Stack.Screen name="Home" component={HomeScreen} />
|
||||
<Stack.Screen name="Details" component={DetailsScreen} />
|
||||
</Stack.Navigator>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (native stack with react-navigation):**
|
||||
|
||||
```tsx
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
|
||||
const Stack = createNativeStackNavigator()
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name='Home' component={HomeScreen} />
|
||||
<Stack.Screen name='Details' component={DetailsScreen} />
|
||||
<Stack.Screen name="Home" component={HomeScreen} />
|
||||
<Stack.Screen name="Details" component={DetailsScreen} />
|
||||
</Stack.Navigator>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -57,10 +57,10 @@ function App() {
|
||||
|
||||
```tsx
|
||||
// app/_layout.tsx
|
||||
import { Stack } from 'expo-router'
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function Layout() {
|
||||
return <Stack />
|
||||
return <Stack />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,46 +69,46 @@ export default function Layout() {
|
||||
**Incorrect (JS bottom tabs):**
|
||||
|
||||
```tsx
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
const Tab = createBottomTabNavigator()
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Tab.Navigator>
|
||||
<Tab.Screen name='Home' component={HomeScreen} />
|
||||
<Tab.Screen name='Settings' component={SettingsScreen} />
|
||||
<Tab.Screen name="Home" component={HomeScreen} />
|
||||
<Tab.Screen name="Settings" component={SettingsScreen} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (native bottom tabs with react-navigation):**
|
||||
|
||||
```tsx
|
||||
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'
|
||||
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
|
||||
|
||||
const Tab = createNativeBottomTabNavigator()
|
||||
const Tab = createNativeBottomTabNavigator();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Tab.Navigator>
|
||||
<Tab.Screen
|
||||
name='Home'
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{
|
||||
tabBarIcon: () => ({ sfSymbol: 'house' }),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='Settings'
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
tabBarIcon: () => ({ sfSymbol: 'gear' }),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -116,21 +116,21 @@ function App() {
|
||||
|
||||
```tsx
|
||||
// app/(tabs)/_layout.tsx
|
||||
import { NativeTabs } from 'expo-router/unstable-native-tabs'
|
||||
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name='index'>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf='house.fill' md='home' />
|
||||
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name='settings'>
|
||||
<NativeTabs.Trigger name="settings">
|
||||
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon sf='gear' md='settings' />
|
||||
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -145,10 +145,10 @@ behind the translucent tab bar. If you need to disable this, use
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name='Profile'
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
header: () => <CustomHeader title='Profile' />,
|
||||
header: () => <CustomHeader title="Profile" />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
@@ -157,7 +157,7 @@ behind the translucent tab bar. If you need to disable this, use
|
||||
|
||||
```tsx
|
||||
<Stack.Screen
|
||||
name='Profile'
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
title: 'Profile',
|
||||
|
||||
+12
-12
@@ -16,35 +16,35 @@ creates new references and breaks memoization.
|
||||
**Incorrect (dotting into object):**
|
||||
|
||||
```tsx
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
function SaveButton(props) {
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
// bad: react-compiler will key the cache on "props" and "router", which are objects that change each render
|
||||
const handlePress = () => {
|
||||
props.onSave()
|
||||
router.push('/success') // unstable reference
|
||||
}
|
||||
props.onSave();
|
||||
router.push('/success'); // unstable reference
|
||||
};
|
||||
|
||||
return <Button onPress={handlePress}>Save</Button>
|
||||
return <Button onPress={handlePress}>Save</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (destructure early):**
|
||||
|
||||
```tsx
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
function SaveButton({ onSave }) {
|
||||
const { push } = useRouter()
|
||||
const { push } = useRouter();
|
||||
|
||||
// good: react-compiler will key on push and onSave
|
||||
const handlePress = () => {
|
||||
onSave()
|
||||
push('/success') // stable reference
|
||||
}
|
||||
onSave();
|
||||
push('/success'); // stable reference
|
||||
};
|
||||
|
||||
return <Button onPress={handlePress}>Save</Button>
|
||||
return <Button onPress={handlePress}>Save</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
+10
-10
@@ -14,32 +14,32 @@ property access—explicit methods ensure correct behavior.
|
||||
**Incorrect (breaks with React Compiler):**
|
||||
|
||||
```tsx
|
||||
import { useSharedValue } from 'react-native-reanimated'
|
||||
import { useSharedValue } from 'react-native-reanimated';
|
||||
|
||||
function Counter() {
|
||||
const count = useSharedValue(0)
|
||||
const count = useSharedValue(0);
|
||||
|
||||
const increment = () => {
|
||||
count.value = count.value + 1 // opts out of react compiler
|
||||
}
|
||||
count.value = count.value + 1; // opts out of react compiler
|
||||
};
|
||||
|
||||
return <Button onPress={increment} title={`Count: ${count.value}`} />
|
||||
return <Button onPress={increment} title={`Count: ${count.value}`} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (React Compiler compatible):**
|
||||
|
||||
```tsx
|
||||
import { useSharedValue } from 'react-native-reanimated'
|
||||
import { useSharedValue } from 'react-native-reanimated';
|
||||
|
||||
function Counter() {
|
||||
const count = useSharedValue(0)
|
||||
const count = useSharedValue(0);
|
||||
|
||||
const increment = () => {
|
||||
count.set(count.get() + 1)
|
||||
}
|
||||
count.set(count.get() + 1);
|
||||
};
|
||||
|
||||
return <Button onPress={increment} title={`Count: ${count.get()}`} />
|
||||
return <Button onPress={increment} title={`Count: ${count.get()}`} />;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+24
-24
@@ -15,29 +15,29 @@ latest value.
|
||||
**Incorrect (reads state directly):**
|
||||
|
||||
```tsx
|
||||
const [size, setSize] = useState<Size | undefined>(undefined)
|
||||
const [size, setSize] = useState<Size | undefined>(undefined);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
// size may be stale in this closure
|
||||
if (size?.width !== width || size?.height !== height) {
|
||||
setSize({ width, height })
|
||||
setSize({ width, height });
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (dispatch updater):**
|
||||
|
||||
```tsx
|
||||
const [size, setSize] = useState<Size | undefined>(undefined)
|
||||
const [size, setSize] = useState<Size | undefined>(undefined);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setSize((prev) => {
|
||||
if (prev?.width === width && prev?.height === height) return prev
|
||||
return { width, height }
|
||||
})
|
||||
}
|
||||
if (prev?.width === width && prev?.height === height) return prev;
|
||||
return { width, height };
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Returning the previous value from the updater skips the re-render.
|
||||
@@ -48,23 +48,23 @@ re-render.
|
||||
**Incorrect (unnecessary comparison for primitive state):**
|
||||
|
||||
```tsx
|
||||
const [size, setSize] = useState<Size | undefined>(undefined)
|
||||
const [size, setSize] = useState<Size | undefined>(undefined);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout
|
||||
setSize((prev) => (prev === width ? prev : width))
|
||||
}
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setSize((prev) => (prev === width ? prev : width));
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (sets primitive state directly):**
|
||||
|
||||
```tsx
|
||||
const [size, setSize] = useState<Size | undefined>(undefined)
|
||||
const [size, setSize] = useState<Size | undefined>(undefined);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout
|
||||
setSize(width)
|
||||
}
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setSize(width);
|
||||
};
|
||||
```
|
||||
|
||||
However, if the next state depends on the current state, you should still use a
|
||||
@@ -73,19 +73,19 @@ dispatch updater.
|
||||
**Incorrect (reads state directly from the callback):**
|
||||
|
||||
```tsx
|
||||
const [count, setCount] = useState(0)
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const onTap = () => {
|
||||
setCount(count + 1)
|
||||
}
|
||||
setCount(count + 1);
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (dispatch updater):**
|
||||
|
||||
```tsx
|
||||
const [count, setCount] = useState(0)
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const onTap = () => {
|
||||
setCount((prev) => prev + 1)
|
||||
}
|
||||
setCount((prev) => prev + 1);
|
||||
};
|
||||
```
|
||||
|
||||
+10
-10
@@ -15,30 +15,30 @@ source changes, not just on initial render.
|
||||
**Incorrect (syncs state, loses reactivity):**
|
||||
|
||||
```tsx
|
||||
type Props = { fallbackEnabled: boolean }
|
||||
type Props = { fallbackEnabled: boolean };
|
||||
|
||||
function Toggle({ fallbackEnabled }: Props) {
|
||||
const [enabled, setEnabled] = useState(defaultEnabled)
|
||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||
// If fallbackEnabled changes, state is stale
|
||||
// State mixes user intent with default value
|
||||
|
||||
return <Switch value={enabled} onValueChange={setEnabled} />
|
||||
return <Switch value={enabled} onValueChange={setEnabled} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (state is user intent, reactive fallback):**
|
||||
|
||||
```tsx
|
||||
type Props = { fallbackEnabled: boolean }
|
||||
type Props = { fallbackEnabled: boolean };
|
||||
|
||||
function Toggle({ fallbackEnabled }: Props) {
|
||||
const [_enabled, setEnabled] = useState<boolean | undefined>(undefined)
|
||||
const enabled = _enabled ?? defaultEnabled
|
||||
const [_enabled, setEnabled] = useState<boolean | undefined>(undefined);
|
||||
const enabled = _enabled ?? defaultEnabled;
|
||||
// undefined = user hasn't touched it, falls back to prop
|
||||
// If defaultEnabled changes, component reflects it
|
||||
// Once user interacts, their choice persists
|
||||
|
||||
return <Switch value={enabled} onValueChange={setEnabled} />
|
||||
return <Switch value={enabled} onValueChange={setEnabled} />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -46,11 +46,11 @@ function Toggle({ fallbackEnabled }: Props) {
|
||||
|
||||
```tsx
|
||||
function ProfileForm({ data }: { data: User }) {
|
||||
const [_theme, setTheme] = useState<string | undefined>(undefined)
|
||||
const theme = _theme ?? data.theme
|
||||
const [_theme, setTheme] = useState<string | undefined>(undefined);
|
||||
const theme = _theme ?? data.theme;
|
||||
// Shows server value until user overrides
|
||||
// Server refetch updates the fallback automatically
|
||||
|
||||
return <ThemePicker value={theme} onChange={setTheme} />
|
||||
return <ThemePicker value={theme} onChange={setTheme} />;
|
||||
}
|
||||
```
|
||||
|
||||
+15
-15
@@ -13,20 +13,20 @@ Use the fewest state variables possible. If a value can be computed from existin
|
||||
|
||||
```tsx
|
||||
function Cart({ items }: { items: Item[] }) {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [itemCount, setItemCount] = useState(0)
|
||||
const [total, setTotal] = useState(0);
|
||||
const [itemCount, setItemCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setTotal(items.reduce((sum, item) => sum + item.price, 0))
|
||||
setItemCount(items.length)
|
||||
}, [items])
|
||||
setTotal(items.reduce((sum, item) => sum + item.price, 0));
|
||||
setItemCount(items.length);
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>{itemCount} items</Text>
|
||||
<Text>Total: ${total}</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -34,15 +34,15 @@ function Cart({ items }: { items: Item[] }) {
|
||||
|
||||
```tsx
|
||||
function Cart({ items }: { items: Item[] }) {
|
||||
const total = items.reduce((sum, item) => sum + item.price, 0)
|
||||
const itemCount = items.length
|
||||
const total = items.reduce((sum, item) => sum + item.price, 0);
|
||||
const itemCount = items.length;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>{itemCount} items</Text>
|
||||
<Text>Total: ${total}</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -50,14 +50,14 @@ function Cart({ items }: { items: Item[] }) {
|
||||
|
||||
```tsx
|
||||
// Incorrect: storing both firstName, lastName, AND fullName
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [fullName, setFullName] = useState('');
|
||||
|
||||
// Correct: derive fullName
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const fullName = `${firstName} ${lastName}`
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const fullName = `${firstName} ${lastName}`;
|
||||
```
|
||||
|
||||
State should be the minimal source of truth. Everything else is derived.
|
||||
|
||||
+5
-5
@@ -20,7 +20,7 @@ function Profile({ name, count }: { name: string; count: number }) {
|
||||
{name && <Text>{name}</Text>}
|
||||
{count && <Text>{count} items</Text>}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
// If name="" or count=0, renders the falsy value → crash
|
||||
```
|
||||
@@ -34,7 +34,7 @@ function Profile({ name, count }: { name: string; count: number }) {
|
||||
{name ? <Text>{name}</Text> : null}
|
||||
{count ? <Text>{count} items</Text> : null}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -47,7 +47,7 @@ function Profile({ name, count }: { name: string; count: number }) {
|
||||
{!!name && <Text>{name}</Text>}
|
||||
{!!count && <Text>{count} items</Text>}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -55,14 +55,14 @@ function Profile({ name, count }: { name: string; count: number }) {
|
||||
|
||||
```tsx
|
||||
function Profile({ name, count }: { name: string; count: number }) {
|
||||
if (!name) return null
|
||||
if (!name) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>{name}</Text>
|
||||
{count > 0 ? <Text>{count} items</Text> : null}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@ direct child of `<View>`.
|
||||
**Incorrect (crashes):**
|
||||
|
||||
```tsx
|
||||
import { View } from 'react-native'
|
||||
import { View } from 'react-native';
|
||||
|
||||
function Greeting({ name }: { name: string }) {
|
||||
return <View>Hello, {name}!</View>
|
||||
return <View>Hello, {name}!</View>;
|
||||
}
|
||||
// Error: Text strings must be rendered within a <Text> component.
|
||||
```
|
||||
@@ -24,13 +24,13 @@ function Greeting({ name }: { name: string }) {
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { View, Text } from 'react-native'
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
function Greeting({ name }: { name: string }) {
|
||||
return (
|
||||
<View>
|
||||
<Text>Hello, {name}!</Text>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
+17
-28
@@ -14,40 +14,33 @@ for animations or a ref for non-reactive tracking.
|
||||
**Incorrect (useState causes jank):**
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ScrollView,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
} from 'react-native'
|
||||
import { useState } from 'react';
|
||||
import { ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
|
||||
|
||||
function Feed() {
|
||||
const [scrollY, setScrollY] = useState(0)
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame
|
||||
}
|
||||
setScrollY(e.nativeEvent.contentOffset.y); // re-renders on every frame
|
||||
};
|
||||
|
||||
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
|
||||
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (Reanimated for animations):**
|
||||
|
||||
```tsx
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedScrollHandler,
|
||||
} from 'react-native-reanimated'
|
||||
import Animated, { useSharedValue, useAnimatedScrollHandler } from 'react-native-reanimated';
|
||||
|
||||
function Feed() {
|
||||
const scrollY = useSharedValue(0)
|
||||
const scrollY = useSharedValue(0);
|
||||
|
||||
const onScroll = useAnimatedScrollHandler({
|
||||
onScroll: (e) => {
|
||||
scrollY.value = e.contentOffset.y // runs on UI thread, no re-render
|
||||
scrollY.value = e.contentOffset.y; // runs on UI thread, no re-render
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
@@ -56,27 +49,23 @@ function Feed() {
|
||||
// unset this if you need higher precision over performance.
|
||||
scrollEventThrottle={16}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (ref for non-reactive tracking):**
|
||||
|
||||
```tsx
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
ScrollView,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
} from 'react-native'
|
||||
import { useRef } from 'react';
|
||||
import { ScrollView, NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
|
||||
|
||||
function Feed() {
|
||||
const scrollY = useRef(0)
|
||||
const scrollY = useRef(0);
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollY.current = e.nativeEvent.contentOffset.y // no re-render
|
||||
}
|
||||
scrollY.current = e.nativeEvent.contentOffset.y; // no re-render
|
||||
};
|
||||
|
||||
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
|
||||
return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />;
|
||||
}
|
||||
```
|
||||
|
||||
+16
-16
@@ -15,37 +15,37 @@ visual values from state using computation or interpolation.
|
||||
**Incorrect (storing the visual output):**
|
||||
|
||||
```tsx
|
||||
const scale = useSharedValue(1)
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
const tap = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
scale.set(withTiming(0.95))
|
||||
scale.set(withTiming(0.95));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
scale.set(withTiming(1))
|
||||
})
|
||||
scale.set(withTiming(1));
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.get() }],
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
**Correct (storing the state, deriving the visual):**
|
||||
|
||||
```tsx
|
||||
const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed
|
||||
const pressed = useSharedValue(0); // 0 = not pressed, 1 = pressed
|
||||
|
||||
const tap = Gesture.Tap()
|
||||
.onBegin(() => {
|
||||
pressed.set(withTiming(1))
|
||||
pressed.set(withTiming(1));
|
||||
})
|
||||
.onFinalize(() => {
|
||||
pressed.set(withTiming(0))
|
||||
})
|
||||
pressed.set(withTiming(0));
|
||||
});
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
@@ -65,16 +65,16 @@ result.
|
||||
|
||||
```tsx
|
||||
// Incorrect: storing derived values
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [height, setHeight] = useState(0)
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setHeight(isExpanded ? 200 : 0)
|
||||
}, [isExpanded])
|
||||
setHeight(isExpanded ? 200 : 0);
|
||||
}, [isExpanded]);
|
||||
|
||||
// Correct: derive from state
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const height = isExpanded ? 200 : 0
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const height = isExpanded ? 200 : 0;
|
||||
```
|
||||
|
||||
State is the minimal truth. Everything else is derived.
|
||||
|
||||
@@ -12,20 +12,20 @@ Use `expo-image` instead of React Native's `Image`. It provides memory-efficient
|
||||
**Incorrect (React Native Image):**
|
||||
|
||||
```tsx
|
||||
import { Image } from 'react-native'
|
||||
import { Image } from 'react-native';
|
||||
|
||||
function Avatar({ url }: { url: string }) {
|
||||
return <Image source={{ uri: url }} style={styles.avatar} />
|
||||
return <Image source={{ uri: url }} style={styles.avatar} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (expo-image):**
|
||||
|
||||
```tsx
|
||||
import { Image } from 'expo-image'
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
function Avatar({ url }: { url: string }) {
|
||||
return <Image source={{ uri: url }} style={styles.avatar} />
|
||||
return <Image source={{ uri: url }} style={styles.avatar} />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -44,12 +44,7 @@ function Avatar({ url }: { url: string }) {
|
||||
**With priority and caching:**
|
||||
|
||||
```tsx
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
priority="high"
|
||||
cachePolicy="memory-disk"
|
||||
style={styles.hero}
|
||||
/>
|
||||
<Image source={{ uri: url }} priority="high" cachePolicy="memory-disk" style={styles.hero} />
|
||||
```
|
||||
|
||||
**Key props:**
|
||||
|
||||
+9
-10
@@ -1,8 +1,7 @@
|
||||
---
|
||||
title: Use Galeria for Image Galleries and Lightbox
|
||||
impact: MEDIUM
|
||||
impactDescription:
|
||||
native shared element transitions, pinch-to-zoom, pan-to-close
|
||||
impactDescription: native shared element transitions, pinch-to-zoom, pan-to-close
|
||||
tags: images, gallery, lightbox, expo-image, ui
|
||||
---
|
||||
|
||||
@@ -16,7 +15,7 @@ zoom, and pan-to-close. Works with any image component including `expo-image`.
|
||||
|
||||
```tsx
|
||||
function ImageGallery({ urls }: { urls: string[] }) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -29,15 +28,15 @@ function ImageGallery({ urls }: { urls: string[] }) {
|
||||
<Image source={{ uri: selected! }} style={styles.fullscreen} />
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (Galeria with expo-image):**
|
||||
|
||||
```tsx
|
||||
import { Galeria } from '@nandorojo/galeria'
|
||||
import { Image } from 'expo-image'
|
||||
import { Galeria } from '@nandorojo/galeria';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
function ImageGallery({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
@@ -48,15 +47,15 @@ function ImageGallery({ urls }: { urls: string[] }) {
|
||||
</Galeria.Image>
|
||||
))}
|
||||
</Galeria>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Single image:**
|
||||
|
||||
```tsx
|
||||
import { Galeria } from '@nandorojo/galeria'
|
||||
import { Image } from 'expo-image'
|
||||
import { Galeria } from '@nandorojo/galeria';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
function Avatar({ url }: { url: string }) {
|
||||
return (
|
||||
@@ -65,7 +64,7 @@ function Avatar({ url }: { url: string }) {
|
||||
<Image source={{ uri: url }} style={styles.avatar} />
|
||||
</Galeria.Image>
|
||||
</Galeria>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+24
-24
@@ -15,63 +15,63 @@ compare values and avoid unnecessary re-renders.
|
||||
**Height only:**
|
||||
|
||||
```tsx
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { View, LayoutChangeEvent } from 'react-native'
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { View, LayoutChangeEvent } from 'react-native';
|
||||
|
||||
function MeasuredBox({ children }: { children: React.ReactNode }) {
|
||||
const ref = useRef<View>(null)
|
||||
const [height, setHeight] = useState<number | undefined>(undefined)
|
||||
const ref = useRef<View>(null);
|
||||
const [height, setHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Sync measurement on mount (RN 0.82+)
|
||||
const rect = ref.current?.getBoundingClientRect()
|
||||
if (rect) setHeight(rect.height)
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (rect) setHeight(rect.height);
|
||||
// Pre-0.82: ref.current?.measure((x, y, w, h) => setHeight(h))
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
setHeight(e.nativeEvent.layout.height)
|
||||
}
|
||||
setHeight(e.nativeEvent.layout.height);
|
||||
};
|
||||
|
||||
return (
|
||||
<View ref={ref} onLayout={onLayout}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Both dimensions:**
|
||||
|
||||
```tsx
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { View, LayoutChangeEvent } from 'react-native'
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { View, LayoutChangeEvent } from 'react-native';
|
||||
|
||||
type Size = { width: number; height: number }
|
||||
type Size = { width: number; height: number };
|
||||
|
||||
function MeasuredBox({ children }: { children: React.ReactNode }) {
|
||||
const ref = useRef<View>(null)
|
||||
const [size, setSize] = useState<Size | undefined>(undefined)
|
||||
const ref = useRef<View>(null);
|
||||
const [size, setSize] = useState<Size | undefined>(undefined);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const rect = ref.current?.getBoundingClientRect()
|
||||
if (rect) setSize({ width: rect.width, height: rect.height })
|
||||
}, [])
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (rect) setSize({ width: rect.width, height: rect.height });
|
||||
}, []);
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setSize((prev) => {
|
||||
// for non-primitive states, compare values before firing a re-render
|
||||
if (prev?.width === width && prev?.height === height) return prev
|
||||
return { width, height }
|
||||
})
|
||||
}
|
||||
if (prev?.width === width && prev?.height === height) return prev;
|
||||
return { width, height };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View ref={ref} onLayout={onLayout}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ Use [zeego](https://zeego.dev) for cross-platform native menus.
|
||||
**Incorrect (custom JS menu):**
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { View, Pressable, Text } from 'react-native'
|
||||
import { useState } from 'react';
|
||||
import { View, Pressable, Text } from 'react-native';
|
||||
|
||||
function MyMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -36,14 +36,14 @@ function MyMenu() {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (native menu with zeego):**
|
||||
|
||||
```tsx
|
||||
import * as DropdownMenu from 'zeego/dropdown-menu'
|
||||
import * as DropdownMenu from 'zeego/dropdown-menu';
|
||||
|
||||
function MyMenu() {
|
||||
return (
|
||||
@@ -55,27 +55,23 @@ function MyMenu() {
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item key='edit' onSelect={() => console.log('edit')}>
|
||||
<DropdownMenu.Item key="edit" onSelect={() => console.log('edit')}>
|
||||
<DropdownMenu.ItemTitle>Edit</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
key='delete'
|
||||
destructive
|
||||
onSelect={() => console.log('delete')}
|
||||
>
|
||||
<DropdownMenu.Item key="delete" destructive onSelect={() => console.log('delete')}>
|
||||
<DropdownMenu.ItemTitle>Delete</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Context menu (long-press):**
|
||||
|
||||
```tsx
|
||||
import * as ContextMenu from 'zeego/context-menu'
|
||||
import * as ContextMenu from 'zeego/context-menu';
|
||||
|
||||
function MyContextMenu() {
|
||||
return (
|
||||
@@ -87,26 +83,26 @@ function MyContextMenu() {
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item key='copy' onSelect={() => console.log('copy')}>
|
||||
<ContextMenu.Item key="copy" onSelect={() => console.log('copy')}>
|
||||
<ContextMenu.ItemTitle>Copy</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item key='paste' onSelect={() => console.log('paste')}>
|
||||
<ContextMenu.Item key="paste" onSelect={() => console.log('paste')}>
|
||||
<ContextMenu.ItemTitle>Paste</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Checkbox items:**
|
||||
|
||||
```tsx
|
||||
import * as DropdownMenu from 'zeego/dropdown-menu'
|
||||
import * as DropdownMenu from 'zeego/dropdown-menu';
|
||||
|
||||
function SettingsMenu() {
|
||||
const [notifications, setNotifications] = useState(true)
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
@@ -118,7 +114,7 @@ function SettingsMenu() {
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='notifications'
|
||||
key="notifications"
|
||||
value={notifications}
|
||||
onValueChange={() => setNotifications((prev) => !prev)}
|
||||
>
|
||||
@@ -127,14 +123,14 @@ function SettingsMenu() {
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Submenus:**
|
||||
|
||||
```tsx
|
||||
import * as DropdownMenu from 'zeego/dropdown-menu'
|
||||
import * as DropdownMenu from 'zeego/dropdown-menu';
|
||||
|
||||
function MenuWithSubmenu() {
|
||||
return (
|
||||
@@ -146,28 +142,28 @@ function MenuWithSubmenu() {
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item key='home' onSelect={() => console.log('home')}>
|
||||
<DropdownMenu.Item key="home" onSelect={() => console.log('home')}>
|
||||
<DropdownMenu.ItemTitle>Home</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='more'>
|
||||
<DropdownMenu.SubTrigger key="more">
|
||||
<DropdownMenu.ItemTitle>More Options</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.Item key='settings'>
|
||||
<DropdownMenu.Item key="settings">
|
||||
<DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item key='help'>
|
||||
<DropdownMenu.Item key="help">
|
||||
<DropdownMenu.ItemTitle>Help</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+11
-11
@@ -15,39 +15,39 @@ for low-level primitives.
|
||||
**Incorrect (JS-based bottom sheet):**
|
||||
|
||||
```tsx
|
||||
import BottomSheet from 'custom-js-bottom-sheet'
|
||||
import BottomSheet from 'custom-js-bottom-sheet';
|
||||
|
||||
function MyScreen() {
|
||||
const sheetRef = useRef<BottomSheet>(null)
|
||||
const sheetRef = useRef<BottomSheet>(null);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button onPress={() => sheetRef.current?.expand()} title='Open' />
|
||||
<Button onPress={() => sheetRef.current?.expand()} title="Open" />
|
||||
<BottomSheet ref={sheetRef} snapPoints={['50%', '90%']}>
|
||||
<View>
|
||||
<Text>Sheet content</Text>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (native Modal with formSheet):**
|
||||
|
||||
```tsx
|
||||
import { Modal, View, Text, Button } from 'react-native'
|
||||
import { Modal, View, Text, Button } from 'react-native';
|
||||
|
||||
function MyScreen() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button onPress={() => setVisible(true)} title='Open' />
|
||||
<Button onPress={() => setVisible(true)} title="Open" />
|
||||
<Modal
|
||||
visible={visible}
|
||||
presentationStyle='formSheet'
|
||||
animationType='slide'
|
||||
presentationStyle="formSheet"
|
||||
animationType="slide"
|
||||
onRequestClose={() => setVisible(false)}
|
||||
>
|
||||
<View>
|
||||
@@ -55,7 +55,7 @@ function MyScreen() {
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -64,7 +64,7 @@ function MyScreen() {
|
||||
```tsx
|
||||
// In your navigator
|
||||
<Stack.Screen
|
||||
name='Details'
|
||||
name="Details"
|
||||
component={DetailsScreen}
|
||||
options={{
|
||||
presentation: 'formSheet',
|
||||
|
||||
@@ -13,42 +13,42 @@ Never use `TouchableOpacity` or `TouchableHighlight`. Use `Pressable` from
|
||||
**Incorrect (legacy Touchable components):**
|
||||
|
||||
```tsx
|
||||
import { TouchableOpacity } from 'react-native'
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
|
||||
function MyButton({ onPress }: { onPress: () => void }) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<Text>Press me</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (Pressable):**
|
||||
|
||||
```tsx
|
||||
import { Pressable } from 'react-native'
|
||||
import { Pressable } from 'react-native';
|
||||
|
||||
function MyButton({ onPress }: { onPress: () => void }) {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Text>Press me</Text>
|
||||
</Pressable>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (Pressable from gesture handler for lists):**
|
||||
|
||||
```tsx
|
||||
import { Pressable } from 'react-native-gesture-handler'
|
||||
import { Pressable } from 'react-native-gesture-handler';
|
||||
|
||||
function ListItem({ onPress }: { onPress: () => void }) {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Text>Item</Text>
|
||||
</Pressable>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+9
-9
@@ -12,7 +12,7 @@ Use `contentInsetAdjustmentBehavior="automatic"` on the root ScrollView instead
|
||||
**Incorrect (SafeAreaView wrapper):**
|
||||
|
||||
```tsx
|
||||
import { SafeAreaView, ScrollView, View, Text } from 'react-native'
|
||||
import { SafeAreaView, ScrollView, View, Text } from 'react-native';
|
||||
|
||||
function MyScreen() {
|
||||
return (
|
||||
@@ -23,18 +23,18 @@ function MyScreen() {
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect (manual safe area padding):**
|
||||
|
||||
```tsx
|
||||
import { ScrollView, View, Text } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { ScrollView, View, Text } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
function MyScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ paddingTop: insets.top }}>
|
||||
@@ -42,23 +42,23 @@ function MyScreen() {
|
||||
<Text>Content</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (native content inset adjustment):**
|
||||
|
||||
```tsx
|
||||
import { ScrollView, View, Text } from 'react-native'
|
||||
import { ScrollView, View, Text } from 'react-native';
|
||||
|
||||
function MyScreen() {
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior='automatic'>
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||
<View>
|
||||
<Text>Content</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+3
-5
@@ -17,10 +17,8 @@ scroll area without re-rendering content.
|
||||
```tsx
|
||||
function Feed({ bottomOffset }: { bottomOffset: number }) {
|
||||
return (
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: bottomOffset }}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: bottomOffset }}>{children}</ScrollView>
|
||||
);
|
||||
}
|
||||
// Changing bottomOffset triggers full layout recalculation
|
||||
```
|
||||
@@ -36,7 +34,7 @@ function Feed({ bottomOffset }: { bottomOffset: number }) {
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
// Changing bottomOffset only adjusts scroll bounds
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user