Skip to content
ReactField

TopicsIntermediate

JavaScript Performance Patterns in React

JavaScript performance patterns in React: combine loops, lazy init, stable selectors, cut render allocations, with path and cost matchers.

Mohamed ElshahawyPublished Apr 2026Updated Oct 2026
4 min read

What you'll learn

  • Paths for hot lists, heavy selectors, loop cost and profile-first work
  • Match JS costs like multi-pass arrays and eager state init
  • Combine loops, lazy init, memoized selectors and stable objects
  • Profile before micro-optimizing

This page covers practical JavaScript-level optimizations inside React components, based on patterns shown in Vercel's Introducing: React Best Practices (opens in a new tab) and react-best-practices examples. The goal is not micro-optimizing everything. It is eliminating repeated work that compounds under real user interaction.

Use the path picker when you know the situation. Use the cost matcher when the Profiler points at JS self-time. For re-render identity rules, see Stop Unnecessary Re-renders.

Four short paths from a JS toolbox toward one-pass loops, memoized selectors, lazy init and a Profiler
Start from the hot path, then apply one pattern.

Pick a JS performance path

Try it: JS performance path

Pick your situation. You get an ordered approach, why it fits, and a first-week plan.

Your path

Cut allocations and passes on the hot list path

Lists that update often amplify multi-pass filters and new object props. Fix the JS cost before blaming React.

  1. Profile before optimizing

    Confirm self-time is in your JS, not network.

  2. Combine loop iterations

    One pass over large arrays when possible.

  3. Avoid object creation in render

    Stop allocating props every render.

  4. Stop Unnecessary Re-renders

    After JS is cheap, fix memo identity issues.

First week

Profile one chat or table screen. Combine multi-pass stats into one loop. Stabilize style and query option objects.

If the Profiler bar is network, fix waterfalls first. This page is for JS that runs during render.

Checkpoint

You suspect a multi-filter stats helper is slow. What first?

Match a JS cost

Cost chips connected to pattern cards for one-pass loops, stable objects, lazy init and profiling
Name the cost, then pick one primary pattern.

Try it: match a JS cost

Choose the cost you suspect. The matcher points to a primary pattern and an optional alternate.

The cost

The same large array is filtered or mapped many times to build related stats.

Start here

Combine loop iterations

One pass builds the same counts with fewer temporary arrays and less GC pressure.

Also consider

Profile before optimizing

Confirm the list is hot before rewriting readable multi-pass code.

Related: Performance hub

The goal is not micro-optimizing everything. It is removing repeated work that compounds under real interaction.

Key takeaway

If the Profiler bar is network, fix waterfalls first. This page is for JS that runs during render.

Combining loop iterations

Left: two separate passes loop over the same row of items. Right: one orange pass that filters and adds in a single trip
Two passes over the same array can often become one.

A real pattern from Vercel examples: chat UIs repeatedly scanning the same messages list for different metrics, badges, and derived groups.

Incorrect (8 passes over messages)TSX
function getChatStats(messages: Message[]) {
const unread = messages.filter((m) => !m.read)
const pinned = messages.filter((m) => m.pinned)
const sentByMe = messages.filter((m) => m.sender === 'me')
const withAttachments = messages.filter((m) => m.attachments.length > 0)
const urgent = messages.filter((m) => m.priority === 'urgent')
const latest = messages.map((m) => m.timestamp).reduce((a, b) => Math.max(a, b), 0)
const totalLength = messages.map((m) => m.text.length).reduce((a, b) => a + b, 0)
const byChannel = messages.reduce((acc, m) => {
acc[m.channel] = (acc[m.channel] ?? 0) + 1
return acc
}, {} as Record<string, number>)
return {
unreadCount: unread.length,
pinnedCount: pinned.length,
sentByMeCount: sentByMe.length,
attachmentCount: withAttachments.length,
urgentCount: urgent.length,
latest,
totalLength,
byChannel,
}
}
Correct (single pass)TSX
function getChatStats(messages: Message[]) {
const stats = {
unreadCount: 0,
pinnedCount: 0,
sentByMeCount: 0,
attachmentCount: 0,
urgentCount: 0,
latest: 0,
totalLength: 0,
byChannel: {} as Record<string, number>,
}
for (const message of messages) {
if (!message.read) stats.unreadCount += 1
if (message.pinned) stats.pinnedCount += 1
if (message.sender === 'me') stats.sentByMeCount += 1
if (message.attachments.length > 0) stats.attachmentCount += 1
if (message.priority === 'urgent') stats.urgentCount += 1
stats.latest = Math.max(stats.latest, message.timestamp)
stats.totalLength += message.text.length
stats.byChannel[message.channel] = (stats.byChannel[message.channel] ?? 0) + 1
}
// 8 passes -> 1 pass over the same array
return stats
}

When the list is large (or updated frequently), this removes repeated iteration cost and GC pressure from intermediate arrays.

Lazy state initialization

Vercel's real-world example pattern: parsing local storage config on every render. The re-render article covers the same habit in the memo rules; this section keeps the JS-focused example.

IncorrectTSX
function Preferences() {
const [config, setConfig] = useState(
JSON.parse(localStorage.getItem('user-config'))
)
return <ConfigView config={config} />
}
CorrectTSX
function Preferences() {
const [config, setConfig] = useState(() =>
JSON.parse(localStorage.getItem('user-config') ?? '{}')
)
return <ConfigView config={config} />
}

The non-lazy version runs JSON.parse(...) on every re-render, but React ignores that value after the first render. That is wasted CPU.

Memoizing stable selectors

Inline sorting and filtering creates fresh arrays every render, even if inputs did not change.

Incorrect (derived inline every render)TSX
function Dashboard({ items, query }: Props) {
const visibleItems = items
.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.score - b.score)
return <List items={visibleItems} />
}
Correct (memoized selector)TSX
function Dashboard({ items, query }: Props) {
const visibleItems = useMemo(() => {
return items
.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.score - b.score)
}, [items, query])
return <List items={visibleItems} />
}

Use React DevTools Profiler with "Why did this render?" enabled to confirm the selector output was triggering re-renders.

Avoiding object creation in render

Left: a factory inside a component producing a pile of new boxes on every render. Right: one orange box created once and kept on a shelf
A new object on every render is a new reference every time. Create it once when it does not change.

Common render-time allocation traps:

Incorrect (new values each render)TSX
function Profile({ id }: { id: string }) {
const query = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})
const isValid = new RegExp('^[a-z0-9-]+$', 'i').test(id)
const options = { staleTime: 60_000 }
return (
<section style={{ margin: 0 }}>
<p>{isValid ? 'valid' : 'invalid'}</p>
<CacheBadge options={options} />
</section>
)
}
Correct (stable references)TSX
const sectionStyle = { margin: 0 }
const USER_ID_REGEX = /^[a-z0-9-]+$/i
const CACHE_OPTIONS = { staleTime: 60_000 }
function Profile({ id }: { id: string }) {
const queryKey = useMemo(() => ['user', id] as const, [id])
const queryFn = useCallback(() => fetchUser(id), [id])
const query = useQuery({
queryKey,
queryFn,
})
const isValid = USER_ID_REGEX.test(id)
return (
<section style={sectionStyle}>
<p>{isValid ? 'valid' : 'invalid'}</p>
<CacheBadge options={CACHE_OPTIONS} />
</section>
)
}

Fix guide:

  • Inline style props: hoist constant objects to module scope when static (same idea for CSS Modules or vanilla className strings: avoid allocating new style objects or template strings every render when the value is static)
  • Hook config objects: memoize values and functions used as options, or hoist static option objects
  • new RegExp() in render: hoist a module-level regex when the pattern is fixed

Checkpoint

A memoized child still re-renders because of style={{ margin: 0 }}. What helps?

How to profile before optimizing

Use React DevTools Profiler first, then optimize only measured hotspots.

TSX
// Recommended profiling routine:
// 1) Open React DevTools -> Profiler tab
// 2) Record while performing a realistic interaction
// 3) Sort by "Self time" and "Render duration"
// 4) Check "Why did this render?" for noisy components
// 5) Apply a targeted optimization
// 6) Re-profile and confirm the change helped

If performance did not improve measurably, revert the change and keep the simpler code.

The key habit

Profile first. Change the hottest bar. Re-profile. Revert noise.

JS performance checklist

  • I profiled a realistic interaction before rewriting helpers
  • Hot multi-pass array work is combined when it matters
  • Expensive useState initializers are lazy
  • Expensive derived arrays use useMemo with clear deps
  • Static style and options objects are not recreated every render
  • Unmeasured memo was not added "just in case"

Further reading

Conclusion

JavaScript cost during render is often the quiet half of a "React is slow" complaint. Fix the work, then fix the identity rules.

The key takeaway

Eliminate repeated work that compounds under real interaction. Leave cold paths alone.

Check your understanding

Test yourself6 questionsQuestion 1 of 6

If the Profiler bar is network waterfalls, what should you do first on this page?

Recap

What to remember from this article

Sections read

0

Questions

0

none answered yet

Quiz

0

not taken yet

Up next: NX Introduction & Architecture · 4 min

Continue