Performance
Skill Shankulkarni/RN-Lib-Claude/plugins/rn-lib-claude/skills/performance
Ship production-grade RN libraries faster — scaffold, audit, and publish with Claude Code.
npx -y skills add Shankulkarni/RN-Lib-Claude --skill performanceAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when building performance-sensitive React Native library components — list virtualization, JS thread safety, bundle size, FlashList, FPS diagnosis, and Hermes-specific patterns.
SKILL.md
8.0 KB, as published. Nobody here has run it
Performance Patterns for RN Libraries
As a library author, your decisions affect every consumer app. These are RN-specific — not general React patterns.
List Virtualization
Never use ScrollView for unbounded lists. It renders all items upfront.
// ❌ ScrollView — 5,000 items = 1–3s freeze
<ScrollView>
{countries.map(c => <CountryRow key={c.code} {...c} />)}
</ScrollView>
// ✅ FlashList — only renders visible items, ~50ms for 5,000 items
import { FlashList } from '@shopify/flash-list'
<FlashList
data={countries}
renderItem={({ item }) => <CountryRow code={item.code} name={item.name} />}
estimatedItemSize={56}
keyExtractor={item => item.code}
/>
Pass primitives to memoized list items — not objects
// ❌ Object prop — new reference every render, memo is useless
<MemoRow data={item} onPress={handlePress} />
// ✅ Primitives — shallow comparison works
<MemoRow code={item.code} name={item.name} onPress={handlePress} />
getItemType for heterogeneous lists
<FlashList
data={feed}
getItemType={item => item.type} // prevents layout thrashing during recycling
renderItem={({ item }) => { ... }}
/>
Hoist callbacks outside renderItem
// ❌ New function per render — breaks all item memoization
<FlashList renderItem={({ item }) => <Row onPress={() => select(item.id)} />} />
// ✅ Stable reference — items receive the same function instance
const handleSelect = useCallback((id: string) => select(id), [])
<FlashList renderItem={({ item }) => <Row id={item.id} onPress={handleSelect} />} />
JS Thread — Don't Block It
The JS thread drives your library. Blocking it drops frames for the consumer's entire app.
What blocks the JS thread:
- Synchronous operations over ~4ms
- Large JSON.parse / JSON.stringify in render
- Unthrottled event handlers (scroll, pan)
- Filtering/sorting large arrays during render
Patterns to avoid:
// ❌ Expensive filter on every render
function CountryPicker({ query }: { query: string }) {
const results = allCountries.filter(c => // runs every render
c.name.toLowerCase().includes(query.toLowerCase())
)
return <FlashList data={results} ... />
}
// ✅ Memoize expensive derivations
function CountryPicker({ query }: { query: string }) {
const results = useMemo(
() => allCountries.filter(c => c.name.toLowerCase().includes(query.toLowerCase())),
[query]
)
return <FlashList data={results} ... />
}
For search/filter with large datasets: debounce the input, not the filter.
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 150) // only refilter after typing stops
const results = useMemo(
() => allCountries.filter(c => c.name.includes(debouncedQuery)),
[debouncedQuery]
)
FPS Targets
| FPS | State | Action |
|---|---|---|
| 55–60 | Smooth | Ship it |
| 45–55 | Stutter | Investigate |
| 30–45 | Noticeable | Fix before publish |
| <30 | Broken | Block publish |
Diagnose in the example app:
- Shake device (or Cmd+D in simulator) → Show Perf Monitor
- JS FPS drops: JS thread blocked — look for expensive renders, heavy useMemo, sync operations
- UI FPS drops only, JS FPS fine: GPU/layout issue — look for shadows,
overflow: hidden, large blurs
Bundle Size — Your Responsibility
Every byte you add gets paid by every consumer app, on every install.
No internal barrel exports
// ❌ Barrel — Metro loads everything even if consumer imports one export
// src/components/index.ts
export * from './Button'
export * from './Input'
export * from './Modal'
// ✅ Export directly from src/index.ts — only the public API
export { Button } from './components/Button'
export { Input } from './components/Input'
sideEffects: false in package.json
{ "sideEffects": false }
Tells bundlers all exports are pure — unused ones are safely tree-shaken.
Size benchmarks
Check with pkg-size.dev or bundlephobia:
| Size (minified + gzip) | Verdict |
|---|---|
| < 5 KB | Great |
| 5–20 KB | Acceptable — document what's included |
| 20–50 KB | Justify — what is this size coming from? |
| > 50 KB | Block unless it bundles a necessary data file (e.g. country database) |
Never bundle what consumers already have
// ❌ Direct dep — shipped twice
"dependencies": { "react-native-reanimated": "^3.0.0" }
// ✅ Peer dep — one instance in the consumer's app
"peerDependencies": { "react-native-reanimated": ">=3.0.0" }
Hermes-Specific Patterns
All RN 0.76+ apps run on Hermes. Write Hermes-friendly code.
Avoid eval and Function() constructors — Hermes uses AOT compilation, dynamic code generation is unsupported and will throw.
Avoid arguments object — use rest params instead:
// ❌ arguments — not optimized by Hermes
function format() { return Array.from(arguments).join(', ') }
// ✅ Rest params — Hermes-optimized
function format(...args: string[]) { return args.join(', ') }
WeakRef and FinalizationRegistry are available in Hermes — use them for optional cleanup of large resources (image caches, native object references) without causing memory leaks.
React Memoization
Use these when the component receives frequent re-renders or passes callbacks to memoized children.
// memo — component whose parent re-renders often but its own props are stable
const CountryRow = memo(({ code, name, onSelect }: CountryRowProps) => (
<Pressable onPress={() => onSelect(code)}>
<Text>{name}</Text>
</Pressable>
))
// useMemo — expensive derived value
const filteredCountries = useMemo(
() => countries.filter(c => c.name.toLowerCase().includes(query)),
[query]
)
// useCallback — stable function reference passed to memoized children
const handleSelect = useCallback((code: string) => {
onSelect?.(code)
}, [onSelect])
Don't over-memoize — memo with object props silently breaks (fails shallow equality). Measure with the Perf Monitor before adding memoization.
React Compiler Compatibility (RN 0.76+, Expo SDK 52+)
React Compiler auto-memoizes components. Write compiler-friendly code so consumers benefit:
// ✅ Destructure function props at render top — compiler can track them
function MyComponent({ onPress, onLongPress }: Props) {
const handlePress = () => onPress()
const handleLongPress = () => onLongPress?.()
return <Pressable onPress={handlePress} onLongPress={handleLongPress} />
}
// ❌ Dot-access in JSX — compiler misses the optimization
<Pressable onPress={() => props.onPress()} />
TextInput — Uncontrolled for Typing Performance
Controlled TextInput sends every keystroke through the JS bridge, which can cause flicker on fast typing. For search inputs in pickers and filters, prefer uncontrolled:
// ❌ Controlled — every keystroke round-trips through JS
<TextInput value={query} onChangeText={setQuery} />
// ✅ Uncontrolled — native manages state during typing, JS only sees final value
<TextInput
defaultValue={initialQuery}
onChangeText={text => {
queryRef.current = text
onQueryChange?.(text)
}}
/>
Never combine value and defaultValue.
Intl / Date Formatting — Module Scope
Intl constructors are expensive. Create once at module scope, not per render.
// ❌ Recreated on every call
function formatDate(date: Date) {
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date)
}
// ✅ Created once
const dateFormatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' })
export function formatDate(date: Date) { return dateFormatter.format(date) }
// ✅ Or useMemo if locale is dynamic
const formatter = useMemo(
() => new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' }),
[locale]
)