Object Reference Stability in React
Fireship nailed it in his React Conf 2025 recap: “The greatest trick the devil ever pulled was making referential stability a prerequisite to writing React apps that don’t blow up.” That’s not hyperbole. It’s the truth about modern React development.
The Core Problem
JavaScript compares primitives by value but objects by reference. When React checks if something changed, it asks: do these two variables point to the same spot in memory? If you create an object inline during render, it gets a new address every time. Same content, different reference.
React’s optimization tools (React.memo, useEffect, useMemo) all depend on shallow comparison using Object.is(). When references flip unnecessarily, these optimizations fail. Your component tree re-renders in a cascade, burning CPU cycles for no reason.
graph TD
A[Parent Renders] --> B{Creates New Object/Array}
B --> C[New Memory Reference]
C --> D[Child Component Receives Prop]
D --> E{React.memo Shallow Compare}
E -->|Reference Changed| F[Child Re-renders]
E -->|Reference Stable| G[Skip Re-render]
F --> H[Entire Subtree Re-renders]
Real Damage
Cloudflare had a major outage because their dashboard kept hammering their own API with unnecessary calls. The culprit? Unstable references in useEffect dependencies. This wasn’t a junior dev mistake on a side project. This was a production incident at a company that keeps half the internet running.
React’s useEffect compares dependencies with shallow equality. Pass it an unstable object and the effect fires every single render. You get infinite loops, memory leaks, or API spam.
What Not to Do
Creating objects inline is deadly:
// ❌ Creates new reference every render
function Dashboard() {
const config = { url: 'api.com', timeout: 5000 };
useEffect(() => {
fetchData(config);
}, [config]); // Effect runs on EVERY render
return <div>Dashboard</div>;
}
Same goes for functions:
// ❌ New function reference every render
function Parent() {
return <MemoizedChild onClick={() => console.log('clicked')} />
}
// Child re-renders every time despite React.memo
const MemoizedChild = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click me</button>;
});
Inline arrays break memoization too:
// ❌ New array reference every render
function UserList() {
const userIds = [1, 2, 3]; // New reference each time
return <UserDisplay ids={userIds} />;
}
const UserDisplay = React.memo(({ ids }) => {
// Re-renders unnecessarily
return ids.map(id => <User key={id} id={id} />);
});
Manual Fixes
useMemo stabilizes objects and arrays:
// ✅ Stable reference
function Dashboard() {
const config = useMemo(() => ({
url: 'api.com',
timeout: 5000
}), []); // Empty deps = stable across all renders
useEffect(() => {
fetchData(config);
}, [config]); // Only runs once on mount
return <div>Dashboard</div>;
}
useCallback does the same for functions:
// ✅ Stable function reference
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // Function identity preserved
return <MemoizedChild onClick={handleClick} />
}
const MemoizedChild = React.memo(({ onClick }) => {
// Only re-renders when actually needed
return <button onClick={onClick}>Click me</button>;
});
The cleanest fix? Move object creation inside your effect:
// ✅ No memoization needed
function Dashboard({ serverUrl, roomId }) {
useEffect(() => {
const options = { serverUrl, roomId }; // Created inside
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]); // Only primitives in deps
return <div>Dashboard</div>;
}
For computed values, useMemo prevents expensive recalculations:
// ✅ Only recalculates when data changes
function ExpensiveList({ data }) {
const sortedData = useMemo(() => {
return data.slice().sort((a, b) => a.value - b.value);
}, [data]);
return <List items={sortedData} />;
}
The useEffectEvent Solution
React 19.2 introduced useEffectEvent, which Fireship mentioned as a workaround for useEffect complexity. This hook solves a specific problem: when you need to read the latest props or state inside an effect without making those values reactive.
Here’s the classic problem it solves:
// ❌ The old dilemma
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
// We want the latest theme, but don't want to reconnect when it changes
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // Adding theme causes unnecessary reconnections
}
useEffectEvent gives you access to the latest values without triggering re-runs:
// ✅ useEffectEvent to the rescue
import { useEffectEvent, useEffect } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
// Always reads the latest theme, no stale closures
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected(); // Call the Effect Event
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // Only reconnect when roomId changes
return <div>Chat Room</div>;
}
This shines for logging and analytics:
// ✅ Log visits with cart count without re-running on cart changes
import { useEffect, useContext, useEffectEvent } from 'react';
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
const onNavigate = useEffectEvent((visitedUrl) => {
// Always logs the current cart count, no stale data
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onNavigate(url);
}, [url]); // Only log when URL changes, not when cart updates
return <div>Page content</div>;
}
graph TD
A[Component Renders] --> B[theme changes]
B --> C{Traditional useEffect}
C --> D[Effect Re-runs]
D --> E[Reconnect Connection]
E --> F[Performance Cost]
A --> G{useEffectEvent}
G --> H[Event Captures Latest theme]
H --> I[Effect Does NOT Re-run]
I --> J[No Reconnection]
Important rules for useEffectEvent:
Only call it inside effects. Don’t pass Effect Events to other components or hooks. The eslint-plugin-react-hooks linter (version 6.1.1 or higher) enforces this.
It’s not a dependency shortcut. Don’t use useEffectEvent just to avoid listing dependencies. That hides bugs. Use it only when you genuinely need the latest value without reactivity.
For non-reactive logic only. If the logic should respond to changes, keep it in the dependency array. useEffectEvent is for reading current values, not controlling when effects run.
The Compiler Changes Everything
React Compiler 1.0 shipped in October 2025. Fireship’s take: “It’ll auto-optimize your app for you so you can stop pretending you care about how many times it re-renders.” He’s right.
The compiler runs at build time. It analyzes your component’s control flow, spots unstable references, and inserts memoization automatically. You write clean code. It handles the performance work. This is fine-grained reactivity without the ceremony.
graph LR
A[Your React Code] --> B[React Compiler]
B --> C{Static Analysis}
C --> D[Detect Unstable References]
D --> E[Auto-inject useMemo]
D --> F[Auto-inject useCallback]
D --> G[Auto-wrap React.memo]
E --> H[Optimized Bundle]
F --> H
G --> H
Here’s what you write:
// You write this
function TodoList({ todos, filter }) {
const filtered = todos.filter(t => t.status === filter);
const handleToggle = (id) => toggleTodo(id);
return filtered.map(todo =>
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
);
}
The compiler transforms it to this:
// Compiler outputs this
function TodoList({ todos, filter }) {
const filtered = useMemo(() =>
todos.filter(t => t.status === filter),
[todos, filter]
);
const handleToggle = useCallback((id) =>
toggleTodo(id),
[]
);
return filtered.map(todo =>
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
);
}
const TodoItem = React.memo(({ todo, onToggle }) => {
// Component implementation
});
But there’s a catch. The compiler needs functional purity. If your components have messy side effects or break the Rules of Hooks, the compiler bails out. Want the performance gains? Write cleaner code.
How to Adopt It
New projects should enable the compiler immediately:
npm install babel-plugin-react-compiler
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
target: '18' // or '19'
}]
]
};
Write normal React code and let it optimize. Done.
Existing codebases need patience. Keep your current useMemo, useCallback, and React.memo calls for now. The compiler works alongside them:
// This is fine during migration
function Component({ data }) {
// Manual memoization you already have
const processed = useMemo(() => expensiveOp(data), [data]);
// Compiler will optimize the rest
const display = format(processed);
return <div>{display}</div>;
}
Focus on refactoring components to be functionally pure. That’s where the real work lives.
The Escape Hatch
Manual memoization isn’t dead. It’s now an escape hatch for when you need explicit control over useEffect dependencies:
// ✅ Manual control for external sync
function ChatRoom({ roomId, serverUrl }) {
// Explicitly stabilize the options object
const options = useMemo(() => ({
serverUrl,
roomId
}), [serverUrl, roomId]);
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // You control exactly when this runs
return <div>Chat Room</div>;
}
Or combine it with useEffectEvent for maximum control:
// ✅ The best of both worlds
function ChatRoom({ roomId, theme, onStatusChange }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
onStatusChange('connected');
});
const config = useMemo(() => ({
serverUrl: 'https://api.example.com',
roomId
}), [roomId]);
useEffect(() => {
const connection = createConnection(config);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [config]); // Precise control over when to reconnect
return <div>Chat Room</div>;
}
If you’re syncing with an external system and need to guarantee when side effects run, manual memoization gives you that control. The compiler can’t read your mind about external timing requirements.
The Bottom Line
Reference instability comes from JavaScript’s memory model, not React’s architecture. Shallow comparison is fast. Deep comparison is expensive. React chose speed.
The compiler shifts the burden from you to the build tool. useEffectEvent solves the non-reactive value problem cleanly. Together, they prove the React team learned something from 15 years of developer complaints.
Write pure components. Enable the compiler. Use useEffectEvent when you need the latest values without reactivity. Stop micromanaging render cycles.