React re-render optimization and JavaScript performance patterns. 28 rules covering unnecessary re-renders, derived state, memoization, functional setState, Set/Map lookups, and RN critical rendering bugs. Auto-load when optimizing re-renders, fixing performance issues, or reviewing render-heavy components.
Source: vercel-labs/agent-skills (react-best-practices + react-native-skills)
28 rules. Apply when writing or reviewing performance-sensitive code.
Move state reads as close as possible to where they're used. This narrows the re-render scope.
// BAD — parent reads state, all children re-render
function Parent() {
const [count, setCount] = useState(0);
return <Child count={count} />;
}
// GOOD — child reads state, only it re-renders
function Parent() {
return <Child />;
}
function Child() {
const count = useStore((s) => s.count);
return <Text>{count}</Text>;
}
Only include values that the effect actually depends on. Avoid objects/arrays that change reference every render.
// BAD — entire config object in deps
useEffect(() => { fetch(config.url); }, [config]);
// GOOD — only the value used
useEffect(() => { fetch(config.url); }, [config.url]);
Don't use useEffect + setState for values derivable from existing state/props.
// BAD — useEffect to compute derived state
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);
useEffect(() => { setCount(items.length); }, [items]);
// GOOD — compute inline or useMemo
const count = items.length;
// or for expensive computations:
const filtered = useMemo(() => items.filter(expensivePredicate), [items]);
When using external stores, select only the derived value you need.
// BAD — subscribes to entire state
const state = useStore();
const isActive = state.items.some((i) => i.active);
// GOOD — subscribes to derived value only
const isActive = useStore((s) => s.items.some((i) => i.active));
When new state depends on previous state, use the functional form to avoid stale closures and unnecessary deps.
// BAD — depends on count in closure
setCount(count + 1);
// GOOD — reads latest value
setCount((prev) => prev + 1);
Pass a function to useState for expensive initial values. The function runs only on mount.
// BAD — runs every render
const [data] = useState(expensiveComputation());
// GOOD — runs once
const [data] = useState(() => expensiveComputation());
Inline default objects/arrays create new references every render, breaking memoization.
// BAD — new array every render
function List({ items = [] }) { ... }
// GOOD — stable reference
const EMPTY_ITEMS: Item[] = [];
function List({ items = EMPTY_ITEMS }) { ... }
When a parent re-renders frequently but a child is expensive and its props rarely change, extract and memoize.
// GOOD
const ExpensiveChild = memo(function ExpensiveChild({ data }: Props) {
return <>{/* heavy rendering */}</>;
});
Only use memo when profiling shows the component is a bottleneck. Don't blanket-memo everything.
Move logic that responds to user actions into event handlers, not effects.
// BAD — effect watches for state change triggered by click
useEffect(() => {
if (submitted) sendForm(data);
}, [submitted]);
// GOOD — handler does it directly
const handleSubmit = () => sendForm(data);
Inner component definitions create new references every render, causing unmount/remount.
// BAD — Header recreated every render
function Page() {
function Header() { return <Text>Title</Text>; }
return <Header />;
}
// GOOD — defined outside
function Header() { return <Text>Title</Text>; }
function Page() { return <Header />; }
Memoization has overhead. Only use it for genuinely expensive computations.
// BAD — memoizing a simple lookup
const label = useMemo(() => items.find((i) => i.id === id)?.label, [items, id]);
// GOOD — just compute it
const label = items.find((i) => i.id === id)?.label;
Wrap non-urgent state updates in startTransition to keep the UI responsive.
import { useTransition } from 'react';
const [isPending, startTransition] = useTransition();
const handleSearch = (query: string) => {
startTransition(() => setSearchResults(filterResults(query)));
};
Values that change frequently but don't need to trigger re-renders belong in refs.
// BAD — re-renders on every scroll
const [scrollY, setScrollY] = useState(0);
// GOOD — no re-renders (use Reanimated useSharedValue for RN)
const scrollY = useRef(0);
Batch DOM reads and writes. In RN, avoid interleaving measure() calls with state updates.
If a function is called multiple times with the same input in a loop, cache the result.
// BAD
items.forEach((item) => {
const config = getConfig(item.type); // called repeatedly with same type
});
// GOOD
const configCache = new Map<string, Config>();
items.forEach((item) => {
if (!configCache.has(item.type)) configCache.set(item.type, getConfig(item.type));
const config = configCache.get(item.type)!;
});
Store frequently accessed properties in a local variable before looping.
// BAD
for (let i = 0; i < items.length; i++) { process(items[i]); }
// GOOD
const len = items.length;
for (let i = 0; i < len; i++) { process(items[i]); }
Don't read from AsyncStorage/SecureStore inside loops or frequent callbacks. Read once and cache.
If you chain .filter().map(), consider a single .reduce() or loop.
// BAD — 2 iterations
const result = items.filter((i) => i.active).map((i) => i.name);
// GOOD — 1 iteration
const result: string[] = [];
for (const i of items) {
if (i.active) result.push(i.name);
}
Check preconditions first and return early to avoid unnecessary computation.
function processItems(items: Item[]) {
if (items.length === 0) return [];
// ... expensive logic
}
// BAD
items.filter((i) => i.active).map((i) => i.name);
// GOOD
items.flatMap((i) => (i.active ? [i.name] : []));
Create RegExp once outside the function, not on every call.
// BAD
function validate(input: string) { return /^[a-z]+$/i.test(input); }
// GOOD
const ALPHA_REGEX = /^[a-z]+$/i;
function validate(input: string) { return ALPHA_REGEX.test(input); }
If you repeatedly search an array by a key, build a Map first.
// BAD — O(n) per lookup
const user = users.find((u) => u.id === targetId);
// GOOD — O(1) per lookup after O(n) build
const userMap = new Map(users.map((u) => [u.id, u]));
const user = userMap.get(targetId);
Before deep-comparing arrays, check lengths first.
function arraysEqual(a: unknown[], b: unknown[]): boolean {
if (a.length !== b.length) return false;
return a.every((val, i) => val === b[i]);
}
// BAD — O(n log n)
const max = [...items].sort((a, b) => b.value - a.value)[0];
// GOOD — O(n)
const max = items.reduce((m, i) => (i.value > m.value ? i : m));
// BAD — O(n) per check
const isAllowed = allowedIds.includes(id);
// GOOD — O(1) per check
const allowedSet = new Set(allowedIds);
const isAllowed = allowedSet.has(id);
sort() mutates the original array. toSorted() returns a new array.
// BAD — mutates
items.sort((a, b) => a.name.localeCompare(b.name));
// GOOD — immutable
const sorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
Bare strings outside <Text> crash the app.
// BAD — crashes
<View>{item.name}</View>
// GOOD
<View><Text>{item.name}</Text></View>
0 && <Component /> renders 0 as text in RN → crash.
// BAD — renders "0" if count is 0 → crash
{count && <Text>{count}</Text>}
// GOOD
{count > 0 && <Text>{count}</Text>}
// or
{count ? <Text>{count}</Text> : null}
| # | Rule | Impact | One-liner |
|---|---|---|---|
| R-1 | Defer state reads | MEDIUM | Read state at usage point, not parent |
| R-2 | Narrow effect deps | LOW | Only include actually-used values |
| R-3 | Derived state inline | MEDIUM | No useEffect for computable values |
| R-4 | Subscribe derived | MEDIUM | Select only what you need from stores |
| R-5 | Functional setState | MEDIUM | setCount(prev => prev + 1) |
| R-6 | Lazy state init | MEDIUM | useState(() => expensive()) |
| R-7 | Stable default params | MEDIUM | Extract default objects to constants |
| R-8 | Memoize components | MEDIUM | memo() only when profiling shows need |
| R-9 | Handlers over effects | MEDIUM | User actions → event handlers |
| R-10 | No inner components | HIGH | Define components at module level |
| R-11 | Skip trivial memo | LOW | Don't memoize simple lookups |
| R-12 | Transitions | MEDIUM | startTransition for non-urgent updates |
| R-13 | useRef transient | MEDIUM | Frequent changes → ref, not state |
| J-1 | Batch reads/writes | MEDIUM | Don't interleave measure + setState |
| J-2 | Cache function calls | MEDIUM | Memoize repeated calls in loops |
| J-3 | Cache property access | LOW-MEDIUM | Local var for loop-accessed props |
| J-4 | Cache storage calls | LOW-MEDIUM | Read once, cache in memory |
| J-5 | Combine iterations | LOW-MEDIUM | Single pass over arrays |
| J-6 | Early return | LOW-MEDIUM | Check preconditions first |
| J-7 | flatMap | LOW-MEDIUM | Filter + map in one pass |
| J-8 | Hoist RegExp | LOW-MEDIUM | Create regex once at module level |
| J-9 | Index maps | LOW-MEDIUM | Map for repeated lookups |
| J-10 | Length check first | MEDIUM-HIGH | Compare lengths before deep compare |
| J-11 | Loop min/max | LOW | reduce() over sort()[0] |
| J-12 | Set/Map lookups | LOW-MEDIUM | Set.has() over Array.includes() |
| J-13 | toSorted() | MEDIUM-HIGH | Immutable sorting |
| C-1 | Text wrapping | CRITICAL | Bare strings crash RN |
| C-2 | No falsy && | CRITICAL | 0 && <X> crashes RN |