Which React pattern is faster
Saw a tweet today about which pattern was most performant:
genuine question for react heads, which of these is most performant? Consider that we may have thousands of them all receiving new x y props on each frame. pic.twitter.com/t2tc7Z9qBG
— Steve Ruiz (@steveruizok)
March 15, 2026
My intuition thought that the minimal effects / work would win. So in this case I presumed A would win bc of it’s doing the least amount of work - just object creation (fast in js)
But tbh I didn’t really know so I ran an experiment!
Table of Contents
- The Experiment - benchmark code and setup
- Results Summary - tldr on which pattern won
- Detailed Analysis - full breakdown from Claude
The Experiment
This is a lot of code but just want to be transparent!
tldr on this - we’re preloading a shit ton of state ahead of time and then using raf (requestAnimationFrame) to update state every 16ms
import { useState, useRef, useLayoutEffect, useEffect, memo } from 'react'
import './App.css'
// ============================================
// Pattern A: Inline styles
// ============================================
const PatternA = memo(function PatternA({ x, y }: { x: number; y: number }) {
return <div className="dot" style={{ transform: `translate(${x}px, ${y}px)` }} />
})
// ============================================
// Pattern B: useRef + useLayoutEffect
// ============================================
const PatternB = memo(function PatternB({ x, y }: { x: number; y: number }) {
const elementRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const elm = elementRef.current
if (!elm) return
elm.style.transform = `translate(${x}px, ${y}px)`
}, [x, y])
return <div className="dot" ref={elementRef} />
})
// ============================================
// Pattern C: Ref callback
// ============================================
const PatternC = memo(function PatternC({ x, y }: { x: number; y: number }) {
return (
<div
className="dot"
ref={(elm) => {
if (elm) elm.style.transform = `translate(${x}px, ${y}px)`
}}
/>
)
})
// ============================================
// Benchmark component
// ============================================
type PatternType = 'A' | 'B' | 'C'
interface Position {
x: number
y: number
}
function App() {
const [pattern, setPattern] = useState<PatternType>('A')
const [componentCount, setComponentCount] = useState(1000)
const [updateInterval, setUpdateInterval] = useState(16)
const [isRunning, setIsRunning] = useState(false)
const [positions, setPositions] = useState<Position[]>(() => {
const initial: Position[] = []
for (let i = 0; i < 1000; i++) {
initial.push({ x: Math.random() * 600, y: Math.random() * 400 })
}
return initial
})
// Reinitialize positions when count changes
useEffect(() => {
const newPositions: Position[] = []
for (let i = 0; i < componentCount; i++) {
newPositions.push({
x: Math.random() * 600,
y: Math.random() * 400,
})
}
setPositions(newPositions)
}, [componentCount])
// Animation loop
useEffect(() => {
if (!isRunning) return
let animationId: number
let lastUpdate = performance.now()
const animate = () => {
const now = performance.now()
if (now - lastUpdate >= updateInterval) {
lastUpdate = now
setPositions(prev =>
prev.map(pos => ({
x: pos.x + (Math.random() - 0.5) * 10,
y: pos.y + (Math.random() - 0.5) * 10,
}))
)
}
animationId = requestAnimationFrame(animate)
}
animationId = requestAnimationFrame(animate)
return () => cancelAnimationFrame(animationId)
}, [isRunning, updateInterval])
const PatternComponent = pattern === 'A' ? PatternA : pattern === 'B' ? PatternB : PatternC
return (
<div className="app">
<header className="header">
<h1>React Transform Pattern Benchmark</h1>
<p>{componentCount} components updating every {updateInterval}ms ({Math.round(1000 / updateInterval)} target FPS)</p>
</header>
<div className="controls">
<div className="control-group">
<label>Pattern:</label>
<div className="button-group">
<button
className={pattern === 'A' ? 'active' : ''}
onClick={() => setPattern('A')}
>
A: Inline Style
</button>
<button
className={pattern === 'B' ? 'active' : ''}
onClick={() => setPattern('B')}
>
B: useLayoutEffect
</button>
<button
className={pattern === 'C' ? 'active' : ''}
onClick={() => setPattern('C')}
>
C: Ref Callback
</button>
</div>
</div>
<div className="control-group">
<label>Components: {componentCount}</label>
<input
type="range"
min="100"
max="5000"
step="100"
value={componentCount}
onChange={(e) => setComponentCount(Number(e.target.value))}
disabled={isRunning}
/>
</div>
<div className="control-group">
<label>Update interval: {updateInterval}ms ({Math.round(1000 / updateInterval)} target FPS)</label>
<input
type="range"
min="4"
max="100"
step="1"
value={updateInterval}
onChange={(e) => setUpdateInterval(Number(e.target.value))}
disabled={isRunning}
/>
</div>
<div className="control-group">
<button className="primary" onClick={() => setIsRunning(true)} disabled={isRunning}>
Start
</button>
<button onClick={() => setIsRunning(false)} disabled={!isRunning}>
Stop
</button>
</div>
</div>
<div className="benchmark-container">
{positions.map((pos, i) => (
<PatternComponent key={i} x={pos.x} y={pos.y} />
))}
</div>
<div className="pattern-info">
<h2>Pattern Descriptions</h2>
<div className="pattern-cards">
<div className={`pattern-card ${pattern === 'A' ? 'active' : ''}`}>
<h3>A: Inline Style</h3>
<pre>{`<div style={{ transform: \`translate(\${x}px, \${y}px)\` }} />`}</pre>
<p>React handles the style object, creating new objects each render.</p>
</div>
<div className={`pattern-card ${pattern === 'B' ? 'active' : ''}`}>
<h3>B: useLayoutEffect + Ref</h3>
<pre>{`useLayoutEffect(() => {
elm.style.transform = \`...\`
}, [x, y])`}</pre>
<p>Direct DOM manipulation after React commit phase.</p>
</div>
<div className={`pattern-card ${pattern === 'C' ? 'active' : ''}`}>
<h3>C: Ref Callback</h3>
<pre>{`ref={(elm) => {
if (elm) elm.style.transform = \`...\`
}}`}</pre>
<p>Sets transform during ref attachment. Creates new function each render.</p>
</div>
</div>
</div>
</div>
)
}
export default App
Visually it looks like:
4x cpu slowdown using Chrome Performance tab
Results Summary
Here’s a summary of the results from Claude:
Key Findings
Winner: inline
| Metric | inline | useLayoutEffect | refCallback | Winner |
|---|---|---|---|---|
| TBT % of Duration | 41.8% | 44.0% | 45.8% | inline |
| GC % of Duration | 15.6% | 17.1% | 18.7% | inline |
| Scripting % of Duration | 87.9% | 86.6% | 85.3% | refCallback |
| Frame Drop Rate | 76.7% | 76.2% | 75.5% | refCallback |
| Long Tasks >200ms/sec | 0.94 | 1.76 | 1.75 | inline |
Conclusion: inline wins on the metrics that matter most for interactivity:
- ~10% lower TBT ratio than refCallback
- ~20% lower GC overhead than refCallback
- Half the rate of severe (>200ms) blocking tasks
The frame drop rates and scripting percentages are essentially a wash (~1-2% difference) - all three approaches are CPU-bound with similar frame delivery struggles
Detailed Analysis
Analysis Date: 2026-03-15 | Test Conditions: 4x CPU Throttling
Since trace durations varied (5,338ms to 5,699ms), metrics are normalized for fair comparison.
At a Glance
| Metric | inline | useLayoutEffect | refCallback | Winner |
|---|---|---|---|---|
| TBT % of Duration | 41.8% | 44.0% | 45.8% | inline |
| GC % of Duration | 15.6% | 17.1% | 18.7% | inline |
| Scripting % of Duration | 87.9% | 86.6% | 85.3% | refCallback |
| Frame Drop Rate | 76.7% | 76.2% | 75.5% | refCallback |
| Long Tasks >200ms/sec | 0.94 | 1.76 | 1.75 | inline |
Winner: inline - Wins on blocking time (TBT), GC pressure, and severe long task frequency. refCallback technically has the lowest frame drop rate, but the difference is marginal (~1-2%).
The Data
Normalized Metrics (Per Second / Percentage)
| Metric | inline | useLayoutEffect | refCallback |
|---|---|---|---|
| Trace Duration | 5,338ms | 5,670ms | 5,699ms |
| Dropped Frames/sec | 92.3 | 91.9 | 92.1 |
| Frame Drop Rate | 76.7% | 76.2% | 75.5% |
| TBT/sec (ms) | 418 | 440 | 458 |
| TBT % of Duration | 41.8% | 44.0% | 45.8% |
| Scripting % of Duration | 87.9% | 86.6% | 85.3% |
| GC % of Duration | 15.6% | 17.1% | 18.7% |
| Long Tasks (>50ms)/sec | 6.18 | 5.82 | 5.44 |
| Long Tasks (>200ms)/sec | 0.94 | 1.76 | 1.75 |
Raw Metrics (Absolute Values)
| Metric | inline | useLayoutEffect | refCallback |
|---|---|---|---|
| Trace Duration | 5,338ms | 5,670ms | 5,699ms |
| Total Frames | 643 | 684 | 695 |
| Dropped Frames | 493 | 521 | 525 |
| Total Blocking Time | 2,233ms | 2,495ms | 2,607ms |
| Scripting Time | 4,691ms | 4,907ms | 4,861ms |
| GC Time | 832ms | 971ms | 1,066ms |
| Tasks >50ms | 33 | 33 | 31 |
| Tasks >100ms | 16 | 16 | 15 |
| Tasks >200ms | 5 | 10 | 10 |
Metric Deep Dives
Total Blocking Time (TBT)
TBT measures the sum of time beyond 50ms for all long tasks - a key interactivity metric.
| Approach | TBT | TBT % of Duration | vs inline |
|---|---|---|---|
| inline | 2,233ms | 41.8% | baseline |
| useLayoutEffect | 2,495ms | 44.0% | +5.3% |
| refCallback | 2,607ms | 45.8% | +9.6% |
Interpretation: inline spends proportionally less time blocking the main thread, leading to better potential interactivity.
Garbage Collection
| Approach | GC Time | GC % of Duration | vs inline |
|---|---|---|---|
| inline | 832ms | 15.6% | baseline |
| useLayoutEffect | 971ms | 17.1% | +9.6% |
| refCallback | 1,066ms | 18.7% | +19.9% |
Interpretation: inline creates less memory pressure. refCallback triggers ~20% more GC relative to its trace duration.
Severe Long Tasks (>200ms)
| Approach | Count | Per Second | vs inline |
|---|---|---|---|
| inline | 5 | 0.94/sec | baseline |
| useLayoutEffect | 10 | 1.76/sec | +87% |
| refCallback | 10 | 1.75/sec | +86% |
Interpretation: inline has half the rate of severe blocking tasks, meaning smoother execution with fewer major jank events.
Frame Performance
| Approach | Drop Rate | Dropped/sec |
|---|---|---|
| inline | 76.7% | 92.3 |
| useLayoutEffect | 76.2% | 91.9 |
| refCallback | 75.5% | 92.1 |
Interpretation: Frame drop rates are essentially equivalent (~1% difference). All approaches struggle equally with frame delivery due to heavy CPU work.
Long Task Distribution (Top 5)
View task duration breakdown by approach
inline: 266ms, 229ms, 222ms, 204ms, 202ms
useLayoutEffect: 256ms, 246ms, 223ms, 218ms, 216ms
refCallback: 255ms, 240ms, 240ms, 229ms, 228ms
Analysis: inline has one outlier (266ms) but its other long tasks are shorter. useLayoutEffect and refCallback have more consistently severe long tasks.
Recommendations
Use inline when:
- You want the lowest Total Blocking Time (best interactivity)
- You want to minimize GC overhead (-20% vs refCallback)
- You need fewer severe jank events (half the rate of >200ms tasks)
Consider useLayoutEffect when:
- You need synchronous DOM measurements before browser paint
- Accept +5% higher TBT and +10% more GC overhead
Consider refCallback when:
- You need to react to ref attachment/detachment
- Refs are conditionally rendered
- Accept +10% higher TBT and +20% more GC overhead
Technical Notes
- All traces recorded with 4x CPU throttling to simulate mobile device performance
- Traces captured from Chrome DevTools Performance panel
- Total Blocking Time (TBT) = sum of (task_duration - 50ms) for all tasks >50ms
- Frame rate target: 60 FPS (16.67ms per frame)
- High frame drop rates (~76%) across all implementations indicate CPU-bound workload
Trace File Information
| File | Compressed | Uncompressed | Events | Duration |
|---|---|---|---|---|
| inline.json.gz | 14.5 MB | 230 MB | 1,200,296 | 5,338ms |
| useLayoutEffect.json.gz | 15.7 MB | 257 MB | 1,351,518 | 5,670ms |
| refCallback.json.gz | 16.2 MB | 268 MB | 1,422,275 | 5,699ms |