Production & Ecosystem
dev build เทียบ production build พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “dev build เทียบ production build พิสูจน์แล้ว”React มีสอง build development build ใหญ่และช้ากว่าโดยตั้งใจ — รวม warning ที่ช่วยได้, check เพิ่ม และ Strict Mode double-invoke ส่วน production build ตัดทั้งหมดนั้นออก นี่ไม่ใช่ claim ที่ต้องเชื่อเฉย ๆ — มันคือวิธีที่ entry point ของ react-dom เองถูกเขียน:
// react-dom's actual cjs/client.js entry pointif (process.env.NODE_ENV === 'production') { checkDCE(); module.exports = require('./cjs/react-dom-client.production.js');} else { module.exports = require('./cjs/react-dom-client.development.js');}environment variable ตัวเดียว, if อันเดียว, ไฟล์คนละไฟล์กันเลยสองไฟล์ ความต่างของขนาดและเนื้อหาจริงและเยอะ:
$ ls -la node_modules/react-dom/cjs/react-dom-client.*.js1182785 react-dom-client.development.js 625168 react-dom-client.production.js
$ grep -c "console.error" node_modules/react-dom/cjs/react-dom-client.development.js261$ grep -c "console.error" node_modules/react-dom/cjs/react-dom-client.production.js2261 จุดที่พร้อม log console.error ใน development เหลือ 2 จุดใน production — รวมถึงอันนี้ที่คุ้นเคยกันดี มีอยู่แค่ในไฟล์ development เท่านั้น:
$ grep -o "Each child in a list should have a unique[^\"]*" \ node_modules/react-dom/cjs/react-dom-client.development.jsEach child in a list should have a uniquebuild tool ของคุณ (Vite / framework) ตั้ง NODE_ENV ให้อัตโนมัติเมื่อคุณรัน production build command:
npm run build # produces the optimized production bundleอย่า ship development build เด็ดขาด — build นี้ช้าและใหญ่กว่าอย่างมีนัยสำคัญ โดยไม่ได้ประโยชน์อะไรกับ user จริงเลย และ <StrictMode> double-invoke เฉพาะใน development; ใน production ไม่ทำอะไรเลย ปล่อยไว้จึงไม่มีต้นทุน
react-dom/static: prerender สำหรับ static HTML
หัวข้อที่มีชื่อว่า “react-dom/static: prerender สำหรับ static HTML”สำหรับ static-site generation (SSG) — HTML ที่ generate ครั้งเดียวล่วงหน้า ไม่ใช่ต่อ request — react-dom/static render tree ทั้งหมดเป็น HTML แล้วรอให้ data โหลดครบก่อน (ต่างจาก streaming SSR API) รันจริงบน Node ธรรมดา กับ probe ของคอร์สนี้:
import { createElement } from 'react';import { prerenderToNodeStream } from 'react-dom/static';
function App() { return createElement('html', null, createElement('head', null, createElement('title', null, 'Static demo')), createElement('body', null, createElement('div', { id: 'root' }, createElement('h1', null, 'Hello from prerenderToNodeStream'))));}
const { prelude } = await prerenderToNodeStream(createElement(App), { bootstrapScripts: ['/main.js'],});
let html = '';for await (const chunk of prelude) html += chunk;console.log(html);output จริงจาก node run-prerender.mjs เป๊ะทุกตัวอักษร:
<!DOCTYPE html><html><head><link rel="preload" as="script" fetchPriority="low" href="/main.js"/><title>Static demo</title></head><body><div id="root"><h1>Hello from prerenderToNodeStream</h1></div><script src="/main.js" id="_R_" async=""></script></body></html>สังเกตสิ่งที่ React เติมให้เอง: <!DOCTYPE html>, <link rel="preload"> resource hint สำหรับ bootstrap script และ tag <script> เองที่มี id generate มาให้ prerenderToNodeStream เจาะจงสำหรับ Node; environment ที่มี Web Streams (Deno, edge runtime) ใช้ prerender ตัวพี่น้องแทน ทั้งคู่ abort แล้ว resume ทีหลังได้ด้วย resumeToPipeableStream/resume สำหรับ partial prerendering
react-dom/server: renderToPipeableStream สำหรับ SSR จริง
หัวข้อที่มีชื่อว่า “react-dom/server: renderToPipeableStream สำหรับ SSR จริง”สำหรับ server rendering ต่อ request จริง (ไม่ใช่ generate ล่วงหน้า) renderToPipeableStream ของ react-dom/server stream HTML ลง Writable ของ Node ทันทีที่ shell พร้อม แทนที่จะรอทุกอย่างเสร็จก่อน:
import { createElement } from 'react';import { renderToPipeableStream } from 'react-dom/server';import { PassThrough } from 'node:stream';
function App() { return createElement('html', null, createElement('body', null, createElement('div', { id: 'root' }, createElement('p', null, 'streamed'))));}
const passthrough = new PassThrough();let out = '';passthrough.on('data', (chunk) => { out += chunk; });
const { pipe } = renderToPipeableStream(createElement(App), { bootstrapScripts: ['/main.js'], onShellReady() { pipe(passthrough); // pipe as soon as the shell is ready, not before }, onError(error) { console.error('onError:', error); },});
await new Promise((r) => setTimeout(r, 20));console.log(out);output จริง:
<!DOCTYPE html><html><head><link rel="preload" as="script" fetchPriority="low" href="/main.js"/></head><body><div id="root"><p>streamed</p></div><script src="/main.js" id="_R_" async=""></script></body></html>สัญญาของ API ที่สำคัญ: pipe เฉพาะข้างใน onShellReady เท่านั้น — ไม่ใช่ก่อนหน้านั้น และไม่ใช่การเมิน callback นี้ไปเลย เพื่อให้ subtree ที่ช้าอยู่หลัง <Suspense> ไม่บล็อก response แรก onShellError (ไม่ได้โชว์ตรงนี้) คือสัญญาณให้ fallback ไปหน้า error แบบ static แทน ถ้าแม้แต่ shell เองก็ยัง fail
hydrateRoot และการอ่าน hydration mismatch จริง
หัวข้อที่มีชื่อว่า “hydrateRoot และการอ่าน hydration mismatch จริง”ฝั่ง client hydrateRoot แปะ React เข้ากับ HTML ที่ server render มาแล้ว แทนที่จะสร้างใหม่ onRecoverableError คือ hook ของคุณสำหรับ failure mode ที่ section นี้พูดถึงพอดี — mismatch ที่ React ปะแก้ให้อัตโนมัติ แต่ยังอยากให้คุณรู้ว่าเกิดขึ้น ตั้ง jsdom ด้วย server HTML ที่ตั้งใจให้ไม่ตรงกับสิ่งที่ client render:
import { JSDOM } from 'jsdom';
const dom = new JSDOM( '<!DOCTYPE html><html><body><div id="root">Server content before hydration.</div></body></html>', { url: 'http://localhost/' },);globalThis.window = dom.window;globalThis.document = dom.window.document;Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true });
const { createElement } = await import('react');const { hydrateRoot } = await import('react-dom/client');
function App() { // Deliberately different from the server-rendered content above, to // trigger a real hydration mismatch. return createElement('div', { id: 'root' }, 'Client content after hydration.');}
const root = hydrateRoot(document.getElementById('root'), createElement(App), { onRecoverableError(error, errorInfo) { console.log('onRecoverableError message:', error.message); console.log('componentStack present:', typeof errorInfo.componentStack === 'string'); },});ข้อความเต็มจริงที่ React 19 log — นี่คือสิ่งที่คุณจะเห็นใน console ของตัวเองเป๊ะ ๆ เมื่อเรื่องนี้เกิดขึ้น คุ้มที่จะอ่านให้ละเอียดสักครั้งแทนที่จะเลื่อนผ่านทุกครั้ง:
onRecoverableError message: Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.- Date formatting in a user's locale which doesn't match the server.- External changing data without sending a snapshot of it along with the HTML.- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<App>+ <div id="root">- Server content before hydration.
componentStack present: trueอ่าน bullet list นั้นเป็น checklist ตรง ๆ ครั้งต่อไปที่เจอ warning นี้ — React บอกสาเหตุที่พบบ่อยที่สุดห้าอันตรง ๆ เรียงลำดับให้เลย diff +/- ท้ายข้อความโชว์เป๊ะว่า client render อะไร (+) เทียบกับที่ server ส่งมา (-) React กู้คืนด้วยการ ทิ้ง HTML ของ server สำหรับ subtree นั้นแล้ว re-render บน client ใหม่ — หน้ายังทำงานได้ แต่คุณเสียประเด็นของ SSR ไปสำหรับ subtree นั้น (ไม่มี first paint แบบไม่กระพริบ) แล้วยังจ่ายค่า render บน server ไปเปล่า ๆ ด้วย
สำหรับกลไกลึก ๆ ว่า “ทำไม mismatch ถึงเกิดในระดับ fiber/reconciliation” ดูโมดูล React Internals ที่กำลังจะมา; ส่วนวิธีที่ render error ที่ ไม่ถูกจับ (ไม่ใช่ hydration ปะแก้อัตโนมัติ) ไปโผล่ที่ UI ดู error boundaries ในโมดูล concurrent — onCaughtError/onUncaughtError คือพี่น้องของ onRecoverableError ทั้งบน hydrateRoot และ createRoot สำหรับ failure mode สองแบบที่ต่างกัน
flowchart TB ssg["react-dom/static prerender / prerenderToNodeStream (SSG — รอทุกอย่างครบ ครั้งเดียว)"] ssr["react-dom/server renderToPipeableStream (SSR ต่อ request — stream ทันทีที่พร้อม)"] hydrate["react-dom/client hydrateRoot (แปะเข้ากับ HTML ที่มีอยู่แล้ว, onRecoverableError เมื่อ mismatch)"] ssg --> hydrate ssr --> hydrate
production build flag และ error reporting
หัวข้อที่มีชื่อว่า “production build flag และ error reporting”ข้อเท็จจริง CLI จริงที่เช็ค currency แล้วสองอย่าง ควรรู้จักชื่อไว้:
vite build --profile [name]— flag จริงและปัจจุบัน: “start built-in Node.js inspector” สำหรับ profile production build ที่ช้า (ไม่ใช่ runtime performance ของ app คุณ) ยืนยันตรงจากvite build --helpบน probe ของคอร์สนี้ (Vite 8.3.1)- minifier default ของ
vite buildใน Vite 8 คือoxcไม่ใช่esbuild— อีกจุดที่ล้าสมัยเร็วถ้าอ่าน tutorial เก่า;--minify esbuild/terserยังใช้ได้ถ้าต้องการ
สำหรับ error reporting ใน production เดินสาย error callback ทั้งสามของ hydrateRoot/createRoot เข้าด้วยกัน — มันครอบคลุมสถานการณ์ที่ต่างกันจริง ๆ สามแบบ ตัวอย่างของ react.dev เองก็แยกให้ชัด:
function reportError({ type, error, errorInfo }) { console.error(type, error, 'Component Stack:', errorInfo.componentStack);}
hydrateRoot(container, <App />, { onCaughtError(error, errorInfo) { // an Error Boundary caught it reportError({ type: 'Caught', error, errorInfo }); }, onUncaughtError(error, errorInfo) { // nothing caught it — the tree crashed reportError({ type: 'Uncaught', error, errorInfo }); }, onRecoverableError(error, errorInfo) { // React patched a hydration mismatch reportError({ type: 'Recoverable', error, errorInfo }); },});จับคู่กับ Sentry (หรือเทียบเท่า) แล้วเอา custom handler พวกนี้ออกใน development เพื่อให้ยังได้ overlay ของ React เองแทนที่จะ report ซ้ำซ้อน
ecosystem: หยิบอะไรมาใช้
หัวข้อที่มีชื่อว่า “ecosystem: หยิบอะไรมาใช้”React ตั้งใจให้เล็ก app จริงจึงประกอบ library ที่ยอมรับกันแล้วไม่กี่ตัว ตัวเลือกปัจจุบันที่ลงตัว:
| ต้องการ | หยิบ |
|---|---|
| Routing (SPA) | React Router — หรือ router ใน framework (Next.js) |
| Server state / data fetching | TanStack Query (React Query) หรือ RTK Query |
| Form | React Hook Form (+ schema validator อย่าง Zod) |
| Client state | Zustand หรือ Redux Toolkit (หรือ Jotai) — เกินกว่าที่ Context จัดการได้สบาย |
| Styling | CSS Modules, Tailwind หรือ CSS-in-JS library |
| Error monitoring | Sentry (พร้อม error-callback ที่เดินสายไว้ข้างบนที่ root) |
ความต่างสำคัญที่ช่วยประหยัดความเจ็บปวดมาก: server state (data จาก API — TanStack Query) และ client state (local UI state — useState/Context/Zustand) เป็นคนละปัญหา การพยายามเก็บ server data ใน client store (loading flag เอง, cache invalidation, refetch) คือการคิดใหม่แบบคลาสสิกที่ data library แก้ให้คุณแล้ว
code-splitting ด้วย lazy
หัวข้อที่มีชื่อว่า “code-splitting ด้วย lazy”bundle ใหญ่ก้อนเดียวหมายถึง user download code ของหน้าที่อาจไม่เคยเข้า React.lazy + <Suspense> แยก component เป็น chunk ของตัวเองที่โหลด on demand:
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard')); // separate chunk
function App() { return ( <Suspense fallback={<Spinner />}> <Dashboard /> {/* downloaded only when this renders */} </Suspense> );}แยกที่ route boundary ก่อน (แต่ละหน้าเป็น chunk) — นั่นคือการ split ที่คุ้มค่าที่สุด และ framework มักทำให้คุณอยู่แล้ว
production checklist
หัวข้อที่มีชื่อว่า “production checklist”- build ด้วย production bundle; ช่องว่าง
console.error261 เทียบ 2 ที่โชว์ข้างบนคือสิ่งที่คุณซื้อจริง ๆ - code-split ที่ route ด้วย
lazy+Suspense - เลือก render API ให้ตรงงาน:
react-dom/staticสำหรับหน้าที่ generate ล่วงหน้า,renderToPipeableStreamของreact-dom/serverสำหรับ SSR ต่อ request,hydrateRootฝั่ง client พร้อม error callback ทั้งสามเดินสายไว้ - มอง output ของ
onRecoverableErrorเป็น bug report จริง ไม่ใช่ noise — checklist ห้าสาเหตุข้างบนคือจุดเริ่มอ่าน - แยก server state (data library) ออกจาก client state (local/store)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”render entry point ทั้งสามนี้ไม่ใช่ตัวแปรที่ใช้แทนกันได้ — แต่ละตัวตอบคำถามคนละข้อ react-dom/static: “ให้ HTML ฉันครั้งเดียว ฉันจะเสิร์ฟมันตลอดไป” (ไม่มี data ตอน request) react-dom/server: “ให้ HTML สำหรับ request นี้ stream มาทันทีที่พร้อม” hydrateRoot: “แปะเข้ากับ HTML ที่มีอยู่แล้ว แล้วบอกฉันทันทีที่ความจริง (client) กับสมมติฐาน (server) ไม่ตรงกัน” hydration mismatch ไม่ใช่ React เปราะบาง — มันคือ React กำลังเช็คสัญญาที่ code ฝั่ง server และ client ของคุณให้ไว้ทั้งคู่ แล้วบอกตรง ๆ ว่าตรงไหนที่ผิดสัญญา
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | ได้อะไร | ต้นทุน |
|---|---|---|
react-dom/static prerender/prerenderToNodeStream | ง่าย, cache ได้, ไม่ต้องมี server ต่อ request | รอทุกอย่างครบก่อน — ไม่ stream ไม่เหมาะกับ content ต่อ user/ต่อ request |
react-dom/server renderToPipeableStream | stream ทันทีที่ shell พร้อม; data ต่อ request ใช้ได้ | ต้องมี Node server process ที่รันอยู่ (หรือ framework จัดการให้) |
เดินสาย onCaughtError/onUncaughtError/onRecoverableError ใน production | มองเห็น error ครบทุกแบบ ไม่มี error type ไหนหลุดไปโดยไม่ถูก report | ต้องเอาออก/ปิดเงื่อนไขใน development ไม่งั้นเสีย dev overlay ของ React เอง |
production build (NODE_ENV=production) | เล็กลง, เร็วขึ้น, console.error call site น้อยลง 259 จุดที่ไม่ได้ช่วย user เลย | ไม่มีต้นทุนสำหรับ deployment จริง — ไม่มีเหตุผลที่จะ ship dev เลย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “hydration mismatch แปลว่าหน้าพัง” React กู้คืนด้วยการทิ้งแล้ว re-render subtree ที่ mismatch บน client — หน้ายังทำงานได้ สิ่งที่เสียคือ ประเด็น ของ SSR สำหรับ subtree นั้น (first paint แบบไม่กระพริบ) แล้วยังจ่ายค่า server-render ไปเปล่า ๆ ตรงนั้นด้วย
- “
prerenderToNodeStreamกับrenderToPipeableStreamคือของเดียวกันแค่ชื่อต่าง”prerenderToNodeStreamรอทั้ง tree (รวม data ที่ gate ด้วย Suspense) ก่อน resolve — เหมาะกับ SSG ตอน buildrenderToPipeableStreamstream shell ทันทีแล้วให้ส่วนที่ช้ากว่ามาทีหลัง — เหมาะกับ SSR ต่อ request - “ความต่างของ dev/production build ส่วนใหญ่คือเรื่อง minify” minify สำคัญ แต่ความต่างที่ใหญ่กว่าคือ check และจุด
console.error: 261 จุดในไฟล์react-dom-clientแบบ development เหลือ 2 ใน production — ต่างกันเป็นลำดับขนาด ไม่ใช่แค่ปัดเศษ - “
onRecoverableErrorกับ error boundary ทำงานเดียวกัน”onRecoverableErrorfire เมื่อ React เอง ปะแก้ความไม่ตรงกันของ hydration อัตโนมัติ error boundary (onCaughtError) จับ error ที่ component tree ของคุณเอง throw ตอน render ทั้งสองคือ failure category คนละแบบ มี callback คนละตัวด้วยเหตุผล
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- call ของ
hydrateRoot/createRootไม่ได้เดินสายonCaughtError/onUncaughtError/onRecoverableErrorเลยสักตัวไหม? code setup production ที่ generate มามักข้ามทั้งสามอันไปเงียบ ๆ กลับไปใช้ default ที่แค่ log console ของ React — โอเคใน development แต่เป็นช่องว่างจริงของ production error monitoring pipe(...)ของrenderToPipeableStreamถูกเรียกทันที นอกonShellReadyไหม? เสี่ยง pipe ก่อนที่ shell จะพร้อมจริง ทำลายจุดประสงค์ทั้งหมดของ callback นั้น- code branch บน
typeof window !== 'undefined'ข้างใน component ที่ server-render ด้วยไหม? นั่นคือ bullet แรกตรง ๆ ในข้อความ hydration-mismatch จริงของ React — สาเหตุจริงที่พบบ่อยและเกิดจากโค้ดเราเองล้วน ๆ - มี
Date.now()/Math.random()/date format ที่ผูกกับ locale อยู่ใน render body ของ component ที่รันบน server ด้วยไหม? ข้อความเดียวกัน bullet ที่สองและสาม - code “production setup” ที่ generate มาข้ามการตั้ง
NODE_ENV=productionไหม (หรือพึ่ง default ของ bundler ที่จริง ๆ ไม่ได้ตั้งให้)? ก่อนวัดขนาด bundle หรือ performance ความต่างของขนาดประมาณ 2 เท่าและจำนวนconsole.errorที่โชว์ในบทนี้คือของจริง — เทียบ dev bundle กับ claim “production” คือความผิดพลาดจริงที่พลาดง่าย
🐛 หา bug ในโค้ดนี้
function UserGreeting() {if (typeof window !== 'undefined') { return <p>Welcome back, {window.localStorage.getItem('name')}!</p>;}return <p>Welcome!</p>;}เฉลย
นี่คือ bullet แรกตรง ๆ ในข้อความ hydration-mismatch จริงของ React 19 ที่ reproduce ในบทนี้: “A server/client branch if (typeof window !== 'undefined')” บน server window เป็น undefined UserGreeting เลย render "Welcome!" บน client window มีอยู่ component เดียวกันเลย render greeting ที่ personalize แทน — mismatch แน่นอนตอน hydration ครั้งแรก ทุกครั้งที่มีชื่อเก็บไว้จริง React กู้คืน (ทิ้งแล้ว re-render subtree นั้นฝั่ง client) หน้าเลยไม่พัง แต่คุณจ่ายค่า server render ที่ HTML ถูกทิ้งไปเปล่า ๆ แล้ว onRecoverableError จริงก็ fire ด้วยข้อความเป๊ะแบบที่โชว์ก่อนหน้านี้ในบทนี้ ทางแก้คือทำให้สอง environment เห็นตรงกัน: อ่าน localStorage ใน effect (หลัง mount, client เท่านั้น) แล้ว render greeting ทั่วไปก่อน หรือส่ง personalization ลงมาเป็น data ที่ server มีจริง (cookie, session) แทนที่จะ branch บน window