Skip to content

Instantly share code, notes, and snippets.

@mfazekas
Created May 18, 2026 12:18
Show Gist options
  • Select an option

  • Save mfazekas/e236beb37d4d07edb71a01c370ba55fa to your computer and use it in GitHub Desktop.

Select an option

Save mfazekas/e236beb37d4d07edb71a01c370ba55fa to your computer and use it in GitHub Desktop.
React 18 Bailout Demo — Issue #230 explainer (import into codesandbox.io)
import React, { useState, useRef, useEffect } from "react";
const S = {
app: { fontFamily: "system-ui, sans-serif", background: "#0f1117", color: "#e4e4e7", minHeight: "100vh", padding: "32px 20px" },
box: { maxWidth: 720, margin: "0 auto" },
h1: { fontSize: 24, marginBottom: 4 },
h2: { fontSize: 18, color: "#60a5fa", margin: "24px 0 12px" },
dim: { color: "#8b8d97", marginBottom: 24, fontSize: 14 },
sec: { background: "#1a1d27", border: "1px solid #333640", borderRadius: 12, padding: 20, marginBottom: 20 },
btn: { fontFamily: "inherit", fontSize: 13, padding: "8px 16px", borderRadius: 8, border: "1px solid #333640", background: "#252830", color: "#e4e4e7", cursor: "pointer", marginRight: 8, marginBottom: 8 },
row: { display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12, margin: "12px 0" },
stat: { background: "#0f1117", border: "1px solid #333640", borderRadius: 8, padding: 12, textAlign: "center" },
lbl: { fontSize: 11, color: "#8b8d97", textTransform: "uppercase", letterSpacing: 0.5 },
val: { fontSize: 28, fontWeight: 700, marginTop: 4, fontVariantNumeric: "tabular-nums" },
warn: { background: "rgba(251,191,36,0.08)", border: "1px solid rgba(251,191,36,0.3)", borderRadius: 8, padding: "12px 16px", margin: "16px 0", fontSize: 13, lineHeight: 1.6 },
danger: { background: "rgba(248,113,113,0.08)", border: "1px solid rgba(248,113,113,0.3)", borderRadius: 8, padding: "12px 16px", margin: "16px 0", fontSize: 13, lineHeight: 1.6 },
ok: { background: "rgba(74,222,128,0.08)", border: "1px solid rgba(74,222,128,0.3)", borderRadius: 8, padding: "12px 16px", margin: "16px 0", fontSize: 13, lineHeight: 1.6 },
pipe: { display: "flex", gap: 8, alignItems: "center", margin: "12px 0", flexWrap: "wrap" },
code: { fontFamily: "monospace", fontSize: 13, background: "#0f1117", border: "1px solid #333640", borderRadius: 8, padding: 14, whiteSpace: "pre", margin: "12px 0", display: "block", overflowX: "auto", lineHeight: 1.5 },
g2: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, margin: "12px 0" },
};
const Phase = ({ label, color, extra }) => (
<div style={{ padding: "8px 14px", borderRadius: 8, background: "#0f1117", border: `1px solid ${color}`, color, fontSize: 13, fontWeight: 600, fontFamily: "monospace", ...extra }}>{label}</div>
);
const Arrow = () => <span style={{ fontSize: 16, color: "#8b8d97" }}>→</span>;
function BailoutDemo() {
const [value, setValue] = useState("hello");
const rc = useRef(0);
const ec = useRef(0);
const rEl = useRef(null);
const eEl = useRef(null);
const gEl = useRef(null);
const mEl = useRef(null);
rc.current++;
if (rEl.current) rEl.current.textContent = rc.current;
const gap = rc.current - ec.current;
if (gEl.current) { gEl.current.textContent = gap; gEl.current.style.color = gap > 0 ? "#f87171" : "#4ade80"; }
if (mEl.current) {
if (gap > 0) { mEl.current.style.display = "block"; mEl.current.querySelector(".gc").textContent = `${gap} render${gap>1?"s":""} ran without effects!`; mEl.current.querySelector(".rv").textContent = `mutated on render #${rc.current}`; }
else mEl.current.style.display = "none";
}
useEffect(() => {
ec.current++;
if (eEl.current) eEl.current.textContent = ec.current;
const g = rc.current - ec.current;
if (gEl.current) { gEl.current.textContent = g; gEl.current.style.color = g > 0 ? "#f87171" : "#4ade80"; }
if (mEl.current) mEl.current.style.display = g > 0 ? "block" : "none";
}, [value]);
return (
<div>
<p style={{ fontSize: 13, marginBottom: 12, color: "#8b8d97" }}>
Calls <code>setValue("hello")</code> when value is already <code>"hello"</code>.
React runs the component but <strong style={{ color: "#fbbf24" }}>bails out</strong> — effects stay frozen.
</p>
<button style={S.btn} onClick={() => setValue("hello")}>setValue("hello") — same value</button>
<div style={S.row}>
<div style={S.stat}><div style={S.lbl}>Renders</div><div ref={rEl} style={{ ...S.val, color: "#e4e4e7" }}>{rc.current}</div></div>
<div style={S.stat}><div style={S.lbl}>Effects</div><div ref={eEl} style={{ ...S.val, color: "#4ade80" }}>{ec.current}</div></div>
<div style={S.stat}><div style={S.lbl}>Skipped</div><div ref={gEl} style={{ ...S.val, color: gap > 0 ? "#f87171" : "#4ade80" }}>{gap}</div></div>
</div>
<div ref={mEl} style={{ ...S.danger, display: gap > 0 ? "block" : "none" }}>
<strong className="gc">{gap} render{gap>1?"s":""} ran without effects!</strong>{" "}
Ref mutated to <code>"<span className="rv">mutated on render #{rc.current}</span>"</code> but no effect ran.
</div>
</div>
);
}
function NoBailoutDemo() {
const [value, setValue] = useState(0);
const rc = useRef(0);
const ec = useRef(0);
const rEl = useRef(null);
const eEl = useRef(null);
const gEl = useRef(null);
rc.current++;
if (rEl.current) rEl.current.textContent = rc.current;
const gap = rc.current - ec.current;
if (gEl.current) { gEl.current.textContent = gap; gEl.current.style.color = gap > 0 ? "#f87171" : "#4ade80"; }
useEffect(() => {
ec.current++;
if (eEl.current) eEl.current.textContent = ec.current;
const g = rc.current - ec.current;
if (gEl.current) { gEl.current.textContent = g; gEl.current.style.color = g > 0 ? "#f87171" : "#4ade80"; }
}, [value]);
return (
<div>
<p style={{ fontSize: 13, marginBottom: 12, color: "#8b8d97" }}>
Increments counter — a <strong>different</strong> value each time. Renders and effects stay in sync.
</p>
<button style={S.btn} onClick={() => setValue(v => v + 1)}>setValue(v =&gt; v + 1) — new value</button>
<div style={S.row}>
<div style={S.stat}><div style={S.lbl}>Renders</div><div ref={rEl} style={{ ...S.val, color: "#e4e4e7" }}>{rc.current}</div></div>
<div style={S.stat}><div style={S.lbl}>Effects</div><div ref={eEl} style={{ ...S.val, color: "#4ade80" }}>{ec.current}</div></div>
<div style={S.stat}><div style={S.lbl}>Skipped</div><div ref={gEl} style={{ ...S.val, color: "#4ade80" }}>{gap}</div></div>
</div>
<div style={S.ok}>Renders and effects stay in sync — no orphaned state.</div>
</div>
);
}
function PropertyDemo() {
const [trigVal, setTrigVal] = useState("hello");
const propRef = useRef({ id: 1, disposed: false, hasListener: false });
const rc = useRef(0);
const ec = useRef(0);
const idC = useRef(1);
const [log, setLog] = useState([]);
const rEl = useRef(null);
const eEl = useRef(null);
const pEl = useRef(null);
const pBadge = useRef(null);
const addLog = (msg, color) => setLog(prev => [{ msg, color, id: Date.now() + Math.random() }, ...prev].slice(0, 15));
rc.current++;
const old = propRef.current;
if (rc.current > 1) { old.disposed = true; old.hasListener = false; propRef.current = { id: ++idC.current, disposed: false, hasListener: false }; }
if (rEl.current) rEl.current.textContent = rc.current;
if (pEl.current) { pEl.current.textContent = `P${propRef.current.id}`; pEl.current.style.borderColor = propRef.current.hasListener ? "#4ade80" : "#fbbf24"; }
if (pBadge.current) { const p = propRef.current; pBadge.current.textContent = p.hasListener ? "LISTENER ✓" : "NO LISTENER ✗"; pBadge.current.style.color = p.hasListener ? "#4ade80" : "#f87171"; }
useEffect(() => {
ec.current++;
if (eEl.current) eEl.current.textContent = ec.current;
propRef.current.hasListener = true;
addLog(`Effect ran → subscribed listener to P${propRef.current.id}`, "#22d3ee");
if (pEl.current) pEl.current.style.borderColor = "#4ade80";
if (pBadge.current) { pBadge.current.textContent = "LISTENER ✓"; pBadge.current.style.color = "#4ade80"; }
}, [trigVal]);
return (
<div>
<p style={{ fontSize: 13, marginBottom: 12, color: "#8b8d97" }}>
Simulates <code>useDisposableMemo</code>: every render disposes + recreates the property.
The effect subscribes a listener — <strong>only if React doesn't bail out</strong>.
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
<button style={{ ...S.btn, borderColor: "#fbbf24", color: "#fbbf24" }} onClick={() => { setTrigVal("hello"); addLog(`Render: disposed P${propRef.current.id}, created P${idC.current+1}`, "#fb923c"); addLog("React BAILOUT — effects skipped", "#fbbf24"); addLog(`⚠ P${idC.current+1} has NO listener!`, "#f87171"); }}>
Re-render (bailout)
</button>
<button style={{ ...S.btn, background: "#60a5fa", color: "#0f1117", borderColor: "#60a5fa" }} onClick={() => { setTrigVal("hello-"+Date.now()); addLog(`Render: disposed P${propRef.current.id}, created P${idC.current+1}`, "#fb923c"); addLog("Effect ran — listener subscribed ✓", "#4ade80"); }}>
Re-render (real)
</button>
<button style={S.btn} onClick={() => { const p = propRef.current; if (p.hasListener && !p.disposed) addLog(`Trigger → P${p.id} received ✓`, "#4ade80"); else addLog(`Trigger → P${p.id} NO listener — LOST ✗`, "#f87171"); }}>
Fire Trigger
</button>
</div>
<div style={S.row}>
<div style={S.stat}><div style={S.lbl}>Renders</div><div ref={rEl} style={{ ...S.val, color: "#e4e4e7" }}>{rc.current}</div></div>
<div style={S.stat}><div style={S.lbl}>Effects</div><div ref={eEl} style={{ ...S.val, color: "#4ade80" }}>{ec.current}</div></div>
<div style={S.stat}><div style={S.lbl}>Property</div><div ref={pEl} style={{ ...S.val, fontSize: 20, border: "2px solid #4ade80", borderRadius: 8, display: "inline-block", padding: "4px 12px" }}>P{propRef.current.id}</div><div ref={pBadge} style={{ fontSize: 11, fontWeight: 600, fontFamily: "monospace", marginTop: 4, color: "#4ade80" }}>{propRef.current.hasListener?"LISTENER ✓":"NO LISTENER ✗"}</div></div>
</div>
<div style={{ background: "#0f1117", border: "1px solid #333640", borderRadius: 8, padding: 12, maxHeight: 180, overflowY: "auto", fontFamily: "monospace", fontSize: 12, lineHeight: 1.8 }}>
{log.length === 0 && <div style={{ color: "#8b8d97" }}>Click buttons above...</div>}
{log.map(e => <div key={e.id} style={{ color: e.color, borderBottom: "1px solid rgba(51,54,64,0.5)", padding: "2px 0" }}>{e.msg}</div>)}
</div>
</div>
);
}
export default function App() {
return (
<div style={S.app}><div style={S.box}>
<h1 style={S.h1}>React Bailout — Interactive Explainer</h1>
<p style={S.dim}>How React skips effects when output is unchanged, and why that breaks render-phase ref mutations.</p>
<div style={S.sec}>
<h2 style={{ ...S.h2, marginTop: 0 }}>Normal Cycle vs Bailout</h2>
<div style={S.pipe}><Phase label="State Update" color="#fb923c" /><Arrow /><Phase label="Render" color="#60a5fa" /><Arrow /><Phase label="Commit" color="#c084fc" /><Arrow /><Phase label="Effects" color="#4ade80" /></div>
<div style={S.pipe}><Phase label="State Update" color="#fb923c" /><Arrow /><Phase label="Render" color="#60a5fa" /><Arrow /><Phase label="Same output?" color="#fbbf24" extra={{ background: "rgba(251,191,36,0.1)" }} /><Arrow /><Phase label="Commit + Effects SKIPPED" color="#f87171" extra={{ background: "rgba(248,113,113,0.1)", textDecoration: "line-through" }} /></div>
<div style={S.warn}>React still <strong>runs your component function</strong>, but if the JSX output is identical, it skips commit and effects. Safe as long as renders have no side effects.</div>
</div>
<div style={S.sec}>
<h2 style={{ ...S.h2, marginTop: 0 }}>Demo 1: See Bailout Happen</h2>
<div style={S.g2}>
<div style={{ background: "#0f1117", border: "1px solid #fbbf24", borderRadius: 8, padding: 14 }}><h4 style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 1, color: "#fbbf24", marginBottom: 8 }}>Trigger Bailout</h4><BailoutDemo /></div>
<div style={{ background: "#0f1117", border: "1px solid #60a5fa", borderRadius: 8, padding: 14 }}><h4 style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 1, color: "#60a5fa", marginBottom: 8 }}>No Bailout (control)</h4><NoBailoutDemo /></div>
</div>
</div>
<div style={S.sec}>
<h2 style={{ ...S.h2, marginTop: 0 }}>Demo 2: How Bailout Orphans a Property</h2>
<PropertyDemo />
</div>
<div style={S.sec}>
<h2 style={{ ...S.h2, marginTop: 0 }}>The Contract Violation</h2>
<code style={S.code}>{`// useDisposableMemo mutates a ref during render
if (depsChanged) {
cleanup(ref.current.value); // ← side effect!
ref.current.value = factory(); // ← side effect!
}
return ref.current.value;
// React: "output unchanged → skip commit + effects"
// Old property: disposed (dead)
// New property: no listener (orphaned)`}</code>
<div style={S.danger}><strong>The rule:</strong> React assumes renders are pure. <code>useDisposableMemo</code> breaks this by disposing native resources via ref mutation. When React bails out, effects never run — triggers silently lost.</div>
</div>
</div></div>
);
}
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>React Bailout Demo</title></head>
<body style="margin:0"><div id="root"></div></body></html>
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(<App />);
{
"name": "react-bailout-demo",
"main": "src/index.js",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment