Created
May 18, 2026 12:18
-
-
Save mfazekas/e236beb37d4d07edb71a01c370ba55fa to your computer and use it in GitHub Desktop.
React 18 Bailout Demo — Issue #230 explainer (import into codesandbox.io)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 => 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> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html><head><meta charset="utf-8"><title>React Bailout Demo</title></head> | |
| <body style="margin:0"><div id="root"></div></body></html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from "react"; | |
| import { createRoot } from "react-dom/client"; | |
| import App from "./App"; | |
| createRoot(document.getElementById("root")).render(<App />); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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