บทเรียน 73 · Frontend · Next.js

useState — ทำให้หน้าเว็บจำค่าได้และวาดใหม่เอง

~22 นาที · ควรผ่าน บทเรียน 71 (component + props) และ บทเรียน 72 (Tailwind) มาก่อน · บทนี้ยาวกว่าปกติเพราะมีกับดัก 4 อันที่ต้องรันให้เห็นกับตา

จนถึงบทที่แล้ว หน้าเว็บของคุณตายสนิท — วาดครั้งเดียวจบ กดปุ่มแล้วไม่มีอะไรเกิดขึ้น
บทนี้คือบทที่หน้าเว็บเริ่มจำค่าได้ และวาดตัวเองใหม่เมื่อค่านั้นเปลี่ยน ซึ่ง React เรียกความสามารถนี้ว่า state
และบทนี้ยังเป็นบทแรกของทั้งกลุ่ม Next.js ที่คุณจะเจอ error ตอน build ถ้าลืมพิมพ์ข้อความสั้น ๆ บรรทัดเดียว — เราจะทำให้มันพังให้ดูก่อน แล้วค่อยเฉลย
กับดักทั้งหมดในบทนี้ ผมกดปุ่มจริงในเบราว์เซอร์แล้วก็อปผลออกมา ไม่ได้เล่าจากความจำ

🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 5 กันยายน 2026)

next        16.3.4   # Turbopack (ตัว build ปริยายของ Next 16)
react       19.2.8
react-dom   19.2.8
typescript  5
node        v24.14.0

ทุกกล่องที่เขียนว่า "ได้ผลว่า…" ในบทนี้มาจากสามคำสั่งนี้ในโปรเจกต์ Next.js จริง:
npx next build — ข้อความ error ทุกตัวก็อปมาจากตรงนี้ ทั้งบรรทัดและลูกศรชี้
npx tsc --noEmit — ตรวจ type อย่างเดียว (จะเห็นว่ามันจับ error ของบทนี้ไม่ได้ ซึ่งสำคัญมาก)
npx next start แล้วกดปุ่มจริงในเบราว์เซอร์ — ตัวเลขบนจอกับข้อความใน console ก็อปมาจากการกดจริงทีละครั้ง

1 · ปัญหาก่อน: หน้าเว็บที่กดแล้วไม่มีอะไรเกิดขึ้น

ลองคิดถึงของที่ง่ายที่สุดในโลก — ปุ่มนับเลข กดทีนึงเลขเพิ่มทีนึง
ถ้าคุณเพิ่งเขียน Go มา 9 วัน สมองจะสั่งให้เขียนแบบนี้ทันที: ประกาศตัวแปร แล้วบวกมันตอนกด

// components/PlainVar.tsx
'use client';

export default function PlainVar() {
  let count = 0;

  return (
    <button
      onClick={() => {
        count = count + 1;
        console.log('[PlainVar] count in memory =', count);
      }}
    >
      Plain: {count}
    </button>
  );
}
// กดปุ่มนี้ 3 ครั้งติดกัน
// 1) ตัวเลขบนหน้าจอจะเป็นเท่าไหร่?
// 2) แล้วใน console จะพิมพ์อะไรออกมา?

คิดว่าได้อะไร? (คำถามนี้มีสองคำตอบ และมันไม่ตรงกัน)

ผมกดจริง 3 ครั้ง แล้วอ่านข้อความบนปุ่มหลังกดแต่ละครั้ง — บนจอไม่ขยับเลย:

start          -> Plain: 0
plain click 1  -> Plain: 0
plain click 2  -> Plain: 0
plain click 3  -> Plain: 0

แต่ใน console ของเบราว์เซอร์ (ก็อปมาจริง) ตัวแปรเพิ่มขึ้นทุกครั้ง:

[log] [PlainVar] count in memory = 1
[log] [PlainVar] count in memory = 2
[log] [PlainVar] count in memory = 3

นี่คือหัวใจของทั้งบท: ตัวแปรเปลี่ยนค่าได้ แต่ไม่มีใครไปบอกหน้าจอ
React วาดหน้าจอด้วยการเรียกฟังก์ชัน PlainVar() แล้วเอาผลไปแปะ · ตราบใดที่ไม่มีใครสั่งให้เรียกใหม่ ภาพบนจอก็ค้างอยู่ที่รอบแรกตลอดไป
แถมยังแย่กว่านั้น: ถ้าวันหนึ่ง React เรียกฟังก์ชันนี้ใหม่ด้วยเหตุอื่น บรรทัด let count = 0 จะทำงานอีกรอบ แล้วค่าที่บวกไว้ก็หายเกลี้ยง

สรุปว่าเราต้องการของที่ทำสองอย่างพร้อมกัน ซึ่งตัวแปรธรรมดาทำไม่ได้:

  1. จำค่าข้ามการวาด — วาดใหม่กี่รอบก็ยังจำได้ว่าตอนนี้เลขคือเท่าไหร่
  2. สั่งวาดใหม่ — พอค่าเปลี่ยน ต้องมีใครไปเคาะ React ว่า "เรียกฟังก์ชันนี้ใหม่ที"

ของชิ้นนั้นชื่อ state และประตูเข้าถึงมันชื่อ useState()

🧠 เทียบให้เห็นภาพ — state คือกระดานคะแนนหน้าสนาม ไม่ใช่กระดาษทดในกระเป๋า

let count คือกระดาษทดในกระเป๋าคุณ คุณขีดเพิ่มได้เรื่อย ๆ แต่คนดูทั้งสนามไม่เห็นอะไรเลย และพอเปลี่ยนกะ คนใหม่มาก็หยิบกระดาษเปล่าใบใหม่มาเริ่มที่ 0
state คือกระดานคะแนนหน้าสนาม — พอคุณกดเปลี่ยนเลขบนกระดาน ทุกคนที่มองอยู่เห็นพร้อมกันทันที และเลขยังอยู่ตรงนั้นแม้คุณจะเดินออกไปแล้วกลับมาใหม่
ที่ไม่เหมือนกัน: กระดานคะแนนจริงเปลี่ยนเลขทันทีที่กด แต่ setCount() ไม่เปลี่ยนค่าให้ทันทีในบรรทัดถัดไป — มันแค่สั่งจองให้เปลี่ยนในรอบวาดถัดไป (หัวข้อ 6 จะรันให้ดูว่ามันเจ็บยังไง) · และกระดานคะแนนมีอันเดียวทั้งสนาม แต่ state เป็นของใครของมันต่อ component แต่ละตัวที่ถูกวาง วาง <Counter /> สามอัน ก็ได้เลขสามชุดที่ไม่ยุ่งกัน

2 · ก่อนแตะ useState ต้องรู้จัก 'use client' ก่อน (ไม่งั้น build พัง)

ตรงนี้คือจุดที่คนเรียน Next.js สะดุดกันเกือบทุกคน และสไลด์ไม่ได้พูดถึงเลย
ใน App Router ที่คุณเรียนมาตั้งแต่บท 70 ทุกไฟล์ใต้ app/ ถือเป็น Server Component โดยปริยาย — มันถูกรันบนเซิร์ฟเวอร์ แปลงเป็น HTML แล้วส่งไปให้เบราว์เซอร์
ซึ่งบนเซิร์ฟเวอร์ไม่มีการกดปุ่ม ไม่มี event ไม่มีอะไรให้จำ → hook อย่าง useState จึงเรียกในนั้นไม่ได้

// app/t73/broken/page.tsx  — สังเกตว่า "ไม่มี" บรรทัด 'use client'
import { useState } from 'react';

export default function BrokenCounter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count is {count}
    </button>
  );
}

รัน npx tsc --noEmit แล้วรัน npx next build — แต่ละอันได้อะไร?

npx tsc --noEmit — เงียบสนิท ผ่านฉลุย (exit code 0 ไม่มีข้อความอะไรเลย) เพราะในสายตา TypeScript โค้ดนี้ถูกต้องทุกประการ

npx next build — พัง ข้อความเต็ม ๆ ที่ได้จริง:

▲ Next.js 16.3.4 (Turbopack)

  Creating an optimized production build ...

> Build error occurred
Error: Turbopack build failed with 1 error:
./app/t73/broken/page.tsx:1:10
Error: You're importing a module that depends on `useState` into a
React Server Component module. This API is only available in Client
Components. To fix, mark the file (or its parent) with the
`"use client"` directive.
    Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
> 1 | import { useState } from 'react';
    |          ^^^^^^^^
  2 |
  3 | export default function BrokenCounter() {
  4 |   const [count, setCount] = useState(0);

อ่านให้ออกทีละท่อน: "importing a module that depends on useState into a React Server Component module" = คุณเอาของที่ต้องใช้ useState ไปใส่ไฟล์ที่ถือเป็นฝั่งเซิร์ฟเวอร์ · "only available in Client Components" = ของชิ้นนี้มีให้ใช้แค่ฝั่งเบราว์เซอร์ · "mark the file … with the "use client" directive" = วิธีแก้อยู่ในบรรทัดนี้แล้ว

⚠️ จุดที่ต้องจำให้ขึ้นใจ: tsc จับ error ตัวนี้ไม่ได้

ผมรัน npx tsc --noEmit กับไฟล์ที่ลืม 'use client' แล้ว มันไม่ด่าอะไรเลยสักคำ — เพราะเส้นแบ่ง server/client เป็นกฎของ Next.js ไม่ใช่กฎของ TypeScript
แปลว่า VS Code จะไม่ขีดเส้นแดงให้คุณ และมันจะไปโผล่ตอน build เท่านั้น
อย่าเชื่อว่า "ไม่มีเส้นแดง = โค้ดใช้ได้" ในโปรเจกต์ Next.js

วิธีแก้คือเติมบรรทัดเดียวไว้บนสุดของไฟล์ เหนือ import ทุกบรรทัด:

❌ วางผิดที่ — ยัง build ไม่ผ่าน

import { useState } from 'react';
'use client';

export default function LateDirective() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>Late: {n}</button>;
}

ผลรันจริงได้ 2 error พร้อมกัน อันแรกคือ The "use client" directive must be placed before other expressions.

✅ บรรทัดแรกสุดของไฟล์ — build ผ่าน

'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>State: {count}</button>;
}

npx next build ได้ ✓ Compiled successfully แล้ว route /t73 ขึ้นเป็น ○ (Static)

ข้อความ error เต็ม ๆ ของฝั่งซ้าย ก็อปมาจริงจาก npx next build:

Error: Turbopack build failed with 2 errors:
./app/t73/late/page.tsx:2:1
Error: The "use client" directive must be placed before other expressions.
Move it to the top of the file to resolve this issue.
  1 | import { useState } from 'react';
> 2 | 'use client';
    | ^^^^^^^^^^^^^
  3 |
  4 | export default function LateDirective() {
  5 |   const [n, setN] = useState(0);

✅ ข่าวดี: ติดครั้งเดียวคุ้มทั้งกิ่ง — พอไฟล์ไหนติด 'use client' แล้ว component ที่ไฟล์นั้น import เข้ามาก็กลายเป็นฝั่ง client ตามไปด้วย
ในตัวอย่างของบทนี้ ผมทำแบบที่ใช้กันจริงในโปรเจกต์: app/t73/page.tsx ยังเป็น Server Component ธรรมดาไม่ต้องติดอะไร แล้วให้ component เล็ก ๆ ใต้ components/t73/ ติด 'use client' ของใครของมัน
ทำแบบนี้ JavaScript ที่ส่งไปเบราว์เซอร์จะน้อยกว่าการเหมาติดไว้บนสุดทั้งหน้า

🏷️ ศัพท์ที่เอาไปคุยกับ senior หรือ AI ได้เลย
Server Component
component ที่รันบนเซิร์ฟเวอร์แล้วส่งผลลัพธ์เป็น HTML ไปให้เบราว์เซอร์ · ใน App Router ทุกไฟล์เป็นแบบนี้โดยปริยาย · พิมพ์บอก AI ได้ว่า "ไฟล์นี้เป็น Server Component แต่ผมต้องใช้ useState ควรแยก component ไหนออกมาเป็น client?"
Client Component / 'use client' directive
ไฟล์ที่ประกาศบรรทัดแรกว่า 'use client' → โค้ดถูกส่งไปรันในเบราว์เซอร์ด้วย จึงใช้ hook และ event ได้ · ถาม senior ได้ว่า "ตรงนี้ควรติด 'use client' ที่ไฟล์ page เลย หรือแยกเป็น component เล็ก ๆ ดี?"
directive
สตริงเปล่า ๆ ที่วางไว้บนสุดของไฟล์แล้วมีความหมายพิเศษกับตัว build (ญาติของ 'use strict' ใน JavaScript) · คำสำคัญเวลาค้นหา error คือประโยค must be placed before other expressions

3 · useState() — หน้าตาและวิธีอ่านมัน

สไลด์เขียนไว้บรรทัดเดียว และมันคือทั้งหมดที่ต้องจำ:

const [state, setState] = useState(initialState);

อ่านจากขวาไปซ้าย จะเข้าใจง่ายที่สุด:

ชิ้นส่วนสไลด์เรียกว่ามันคืออะไรจริง ๆ
initialStateInitial state ค่าตั้งต้น · ใช้เฉพาะรอบวาดแรกสุด รอบต่อ ๆ ไป React ไม่สนใจค่านี้แล้ว
stateCurrent state ค่าปัจจุบัน ของรอบวาดนี้ · อ่านอย่างเดียว ห้ามเขียนทับตรง ๆ
setStateSet function ฟังก์ชันที่ใช้ขอให้เปลี่ยนค่า แล้ว React จะวาด component นี้ใหม่ให้

วงเล็บก้ามปู [state, setState] ไม่ใช่ไวยากรณ์พิเศษของ React — มันคือ array destructuring ตัวเดียวกับที่คุณใช้ในบท TypeScript
useState คืน array ยาว 2 ช่องเสมอ: ช่อง 0 คือค่า · ช่อง 1 คือฟังก์ชันเซ็ตค่า แล้วเราตั้งชื่อสองช่องนั้นเองในบรรทัดเดียว

// สองบรรทัดนี้ทำงานเหมือนกันเป๊ะ
const [count, setCount] = useState(0);

const pair = useState(0);
const count = pair[0];
const setCount = pair[1];   // แค่ยาวกว่าและไม่มีใครเขียนแบบนี้

🧷 ธรรมเนียมการตั้งชื่อ: ชื่อช่องที่สองให้ขึ้นต้นด้วย set + ชื่อช่องแรกขึ้นต้นตัวใหญ่เสมอ — [count, setCount] · [name, setName] · [isOpen, setIsOpen]
ไม่ใช่กฎที่บังคับด้วยเครื่อง แต่ทุกโปรเจกต์ทำแบบนี้ ผิดธรรมเนียมแล้วคนอ่านสะดุด

อีกเรื่องที่ได้ฟรีเพราะเราเขียน TypeScript: type ของ state ถูกเดาจากค่าตั้งต้น
useState(0) → count เป็น number และ setCount รับได้แค่ number · ลองยัดสตริงเข้าไปแล้วรัน npx tsc --noEmit ได้ผลจริงว่า:

components/t73/TypeSlip.tsx(7,42): error TS2345: Argument of type 'string'
is not assignable to parameter of type 'SetStateAction<number>'.

SetStateAction<number> ในข้อความนั้นแปลว่า "สิ่งที่ setCount รับได้" ซึ่งมีสองแบบ: ตัวเลขตรง ๆ หรือ ฟังก์ชันที่รับเลขเก่าแล้วคืนเลขใหม่ — แบบที่สองคือพระเอกของหัวข้อ 6 จำชื่อนี้ไว้ก่อน

🏷️ ศัพท์ที่เอาไปคุยกับ senior หรือ AI ได้เลย
state
ค่าที่ component จำไว้ข้ามการวาด และเมื่อเปลี่ยนแล้วทำให้หน้าจอวาดใหม่ · ถาม senior ได้ว่า "ค่านี้ควรเป็น state หรือคำนวณสด ๆ จากของที่มีอยู่แล้วดีกว่า?"
hook
ฟังก์ชันของ React ที่ชื่อขึ้นต้นด้วย use และเรียกได้เฉพาะบนสุดของ component · useState คือตัวแรกที่คุณเจอ บทหน้าจะเจอ useEffect · ค้นหาได้ด้วยคำว่า "Rules of Hooks"
initial state
ค่าที่ส่งเข้า useState(...) · ใช้เฉพาะรอบวาดแรก · พิมพ์บอก AI ได้ว่า "ค่าตั้งต้นของ state นี้มาจาก props แต่พอ props เปลี่ยนแล้ว state ไม่ตาม ทำไม?" (เป็นคำถามคลาสสิกที่เกิดจากการเข้าใจคำนี้ผิด)
array destructuring
ไวยากรณ์ const [a, b] = someArray ของ JavaScript/TypeScript ที่ใช้ตั้งชื่อสมาชิกในบรรทัดเดียว · ไม่ใช่ของ React — ถ้าอ่านบรรทัด useState ไม่ออก ให้ทวนเรื่องนี้ก่อน

4 · เซ็ตค่าแล้วเกิดอะไรขึ้น — วงจรที่ต้องเห็นในหัว

สไลด์วาดเป็นวงกลม 6 ขั้น หัวใจของมันคือประโยคเดียว: เรียก set function → React เรียกฟังก์ชัน component นั้นใหม่ทั้งตัว → เอาผลไปแปะบนจอ

  คุณกดปุ่ม
      │
      ▼
  setCount(1)                    ← คุณเขียนเอง
      │
      ▼
  React จดไว้ว่า "count รอบหน้า = 1"
      │
      ▼
  React เรียก Counter() ใหม่ทั้งฟังก์ชัน       ← "re-render"
  (คราวนี้ useState(0) คืน count = 1 ให้)
      │
      ▼
  ได้ JSX ก้อนใหม่ → React เทียบกับของเดิม
      │
      ▼
  แก้เฉพาะจุดที่ต่างบนหน้าจอจริง               ← "commit"
      │
      └──► ค่าใหม่กลายเป็นค่าตั้งต้นของรอบถัดไป (วนกลับขึ้นบน)

ประโยคที่สไลด์หน้า useState() เขียนไว้ตรงนี้สำคัญมาก: "When the value updates, React will re-run the function and re-render."
คำว่า re-run the function แปลตรงตัวว่า โค้ดทุกบรรทัดใน component นั้นถูกรันใหม่ รวมถึงบรรทัด useState(0) ด้วย — แต่คราวนี้ React ไม่ได้คืน 0 ให้ มันคืนค่าที่จดไว้

ผมพิสูจน์ด้วยการวาง console.log ไว้นอก event handler แล้วกดปุ่ม 1 ครั้ง:

// ก่อนกด (ตอนหน้าเว็บโหลดเสร็จ)
[log] render with name = Taylor

// หลังกด 1 ครั้ง — ฟังก์ชันถูกเรียกใหม่จริง ๆ
[log] render with name = Robin
🧠 เทียบให้เห็นภาพ — re-render คือถ่ายรูปใหม่ ไม่ใช่ลบรอยดินสอ

ถ้าคุณเขียนรายงานด้วยดินสอ แล้วอยากแก้เลข คุณจะลบแล้วเขียนทับ — นั่นคือวิธีของ let count = count + 1
React ไม่ทำแบบนั้น มันถ่ายรูปใหม่ทั้งหน้าจากข้อมูลชุดใหม่ แล้วเอาสองรูปมาทาบกัน ว่าตรงไหนต่าง แล้วแก้เฉพาะตรงนั้นบนจอ
ที่ไม่เหมือนกัน: การถ่ายรูปใหม่ในชีวิตจริงแพงมาก แต่ React "ถ่าย" ในหน่วยความจำ ซึ่งถูกจนถ่ายทุกครั้งที่กดปุ่มได้สบาย ๆ · และรูปเก่าไม่ได้หายไปไหน — ค่าในรูปเก่ายังถูกใช้ต่อโดยโค้ดที่กำลังรันค้างอยู่ ซึ่งเป็นต้นตอของกับดักสองอันถัดไปพอดี

5 · กับดัก 1: setCount(count + 1) สามครั้งติด ได้ +1 ไม่ใช่ +3

นี่คือกับดักที่เจอบ่อยที่สุดในโค้ดของมือใหม่ และมันไม่พังแบบมี error ให้เห็น — มันแค่ได้เลขผิด

'use client';
import { useState } from 'react';

export default function TripleStale() {
  const [count, setCount] = useState(0);

  const addThree = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };

  return <button onClick={addThree}>Stale: {count}</button>;
}

กดปุ่มนี้ 1 ครั้ง แล้ว 2 ครั้ง จะเห็นเลขอะไรบนจอ?

ผลจากการกดจริง:

start          -> Stale: 0
stale click 1  -> Stale: 1     // ไม่ใช่ 3
stale click 2  -> Stale: 2     // ไม่ใช่ 6

ได้ครั้งละ +1 ทั้งที่สั่ง setCount ถึงสามครั้ง
เพราะในรอบวาดนั้น count เป็นค่านิ่ง ๆ ค่าเดียวคือ 0 ทั้งสามบรรทัดจึงแปลได้ว่า setCount(0 + 1) · setCount(0 + 1) · setCount(0 + 1) — สั่งค่าเดิมสามหน ผลลัพธ์ก็คือ 1

ทางแก้คือส่งฟังก์ชันให้ setCount แทนที่จะส่งค่า
ฟังก์ชันนั้นรับ "ค่าล่าสุดที่คิวรู้" เข้ามา แล้วคืนค่าใหม่ — React จะเอาแต่ละคิวมาต่อกันตามลำดับ

❌ ส่งค่า — อ่าน count ของรอบเก่า

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

กด 1 ครั้งได้ Stale: 1 · กด 2 ครั้งได้ Stale: 2

✅ ส่งฟังก์ชัน — รับค่าล่าสุดเข้ามา

setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);

กด 1 ครั้งได้ Updater: 3 · กด 2 ครั้งได้ Updater: 6

ผลรันจริงของทั้งสองปุ่ม กดสลับกันในหน้าเดียวกัน:

stale click 1    -> Stale: 1
stale click 2    -> Stale: 2
updater click 1  -> Updater: 3
updater click 2  -> Updater: 6

⚠️ อย่าเหมาเปลี่ยนทุกที่เป็น (c) => c + 1 เพราะกลัวผิด

ใช้แบบส่งฟังก์ชันเมื่อค่าใหม่คำนวณจากค่าเก่า — บวก ลบ สลับ true/false ต่อท้าย array
ถ้าค่าใหม่ไม่เกี่ยวกับค่าเก่าเลย เช่น setName('Robin') หรือ setCount(0) ตอนกดปุ่มรีเซ็ต — เขียนส่งค่าตรง ๆ อ่านง่ายกว่ามาก
สังเกตว่าโค้ดในสไลด์หน้า State of Counter ก็ใช้แบบส่งฟังก์ชัน (setCount((count) => count + 1)) เพราะเป็นการบวกจากค่าเก่าพอดี

6 · กับดัก 2: เซ็ตแล้วบรรทัดถัดไปยังเห็นค่าเก่า

สไลด์หน้า useState() Pitfall เขียนเตือนไว้ตรง ๆ ว่า "Calling the set function does NOT change the current state in the already executing code" — เรามารันดูว่ามันจริงแค่ไหน

'use client';
import { useState } from 'react';

export default function LogAfterSet() {
  const [name, setName] = useState('Taylor');

  const handleClick = () => {
    setName('Robin');
    console.log('name right after setName =', name);
  };

  console.log('render with name =', name);

  return <button onClick={handleClick}>Name: {name}</button>;
}

กด 1 ครั้ง — console จะพิมพ์อะไรบ้าง เรียงยังไง? และปุ่มจะเขียนว่าอะไร?

console จริงหลังกด 1 ครั้ง (ก็อปมาตามลำดับที่ออกมาจริง):

[log] name right after setName = Taylor
[log] render with name = Robin

และข้อความบนปุ่มเปลี่ยนเป็น Name: Robin เรียบร้อย

อ่านสองบรรทัดนี้ให้ขาด: บรรทัดใน handleClick ยังเห็น Taylor ทั้งที่เพิ่งสั่ง setName('Robin') ไปหนึ่งบรรทัดก่อนหน้า
แล้ว Robin ถึงโผล่ในบรรทัดที่รันตอนวาดรอบใหม่
เพราะ name ในรอบวาดนี้เป็นค่าที่ถูกล็อกไว้แล้วตั้งแต่ตอน React เรียกฟังก์ชัน — setName ไม่ได้ไปแก้ตัวแปรตัวนั้น มันแค่ขอให้มีรอบวาดใหม่ที่ค่าเป็น Robin

🧠 เทียบให้เห็นภาพ — set function คือส่งใบคำร้อง ไม่ใช่แก้เอกสารในมือ

ตอน React เรียกฟังก์ชัน component มันยื่นเอกสารสำเนาที่พิมพ์ค่าปัจจุบันไว้แล้วให้คุณถือ
setName('Robin') คือคุณส่งใบคำร้องเข้าไปที่ออฟฟิศว่าขอเปลี่ยนชื่อ — สำเนาที่คุณถืออยู่ในมือยังพิมพ์ว่า Taylor เหมือนเดิม เพราะออฟฟิศไม่ได้วิ่งมาแก้กระดาษในมือคุณ เดี๋ยวเขาจะพิมพ์สำเนาใบใหม่มาให้ตอนรอบหน้า
ที่ไม่เหมือนกัน: ใบคำร้องจริงต้องรอเป็นวัน แต่ของ React เสร็จภายในเสี้ยววินาที เร็วจนคุณเห็นเลขเปลี่ยนทันทีที่ปล่อยนิ้ว · และถ้าส่งใบคำร้องหลายใบในคลิกเดียว ออฟฟิศจะรวบทำทีเดียว (batching) ไม่ได้ทำทีละใบแล้ววาดจอทีละรอบ — ซึ่งเป็นเหตุผลเดียวกับที่หัวข้อ 5 ได้ +1 ไม่ใช่ +3

🏷️ ศัพท์ที่เอาไปคุยกับ senior หรือ AI ได้เลย
re-render
การที่ React เรียกฟังก์ชัน component ใหม่ทั้งตัวเพราะ state (หรือ props) เปลี่ยน · ถาม senior ได้ว่า "component นี้ re-render บ่อยเกินไปไหม ตรงไหนที่ควรย้าย state ลงไปข้างล่าง?"
state ของรอบวาด (snapshot) / stale state
ค่าที่ถูกล็อกไว้ตั้งแต่ต้นรอบวาด · ถ้าโค้ดไปอ่านค่าเก่าแล้วคำนวณผิด ฝรั่งเรียกอาการนั้นว่า stale state · พิมพ์บอก AI ได้ว่า "ปุ่มนี้บวกทีละ 1 ทั้งที่เรียก setCount สามครั้ง น่าจะเป็น stale state ช่วยดูให้หน่อย"
updater function
รูปแบบ setCount((c) => c + 1) ที่ส่งฟังก์ชันเข้าไปแทนค่า · คือคำตอบมาตรฐานของอาการ stale state · ค้นหาได้ด้วยคำว่า "React updater function form of setState"
batching
การที่ React รวบ setState หลายครั้งในเหตุการณ์เดียวไว้วาดจอรอบเดียว · เป็นเหตุผลที่กดปุ่มทีเดียวแล้วหน้าไม่กระพริบสามรอบ

7 · state หลายตัวในหนึ่ง component

เรียก useState กี่ครั้งก็ได้ในหนึ่ง component แต่ละครั้งได้กล่องเก็บค่าคนละใบ ไม่ยุ่งกัน — ตรงตามที่สไลด์หน้า useState() Usage แสดงไว้

'use client';

import { useState } from 'react';

export default function Profile() {
  const [age, setAge] = useState(10);
  const [name, setName] = useState('Taylor');

  return (
    <div>
      <button onClick={() => setAge(age + 1)}>Age: {age}</button>
      <button onClick={() => setName('Robin')}>Who: {name}</button>
    </div>
  );
}

กดจริงสลับกัน ได้ผลว่าอีกตัวไม่ถูกแตะเลย:

start                -> Age: 10 | Who: Taylor
after age click      -> Age: 11 | Who: Taylor
after age click 2    -> Age: 12 | Who: Taylor
after name click     -> Age: 12 | Who: Robin

🧷 แล้ว React รู้ได้ยังไงว่า useState ตัวไหนคือตัวไหน?

มันจำด้วยลำดับการเรียก — ตัวแรกคือช่อง 1 ตัวที่สองคือช่อง 2 ทุกรอบวาดต้องเรียงเหมือนเดิมเป๊ะ
นี่คือเหตุผลของกฎที่คุณจะเจอในบทถัดไป: ห้ามเรียก hook ใน if หรือใน loop เพราะบางรอบเรียก 2 ตัว บางรอบเรียก 1 ตัว แล้วช่องจะเลื่อนกันมั่ว
บทนี้ยังไม่ลงรายละเอียด แค่จำไว้ว่า useState ต้องอยู่บนสุดของ component เสมอ

✅ เกร็ดที่เห็นจากผลรันจริง — ตอน npx next build ผมเห็นบรรทัดนี้โผล่มาระหว่างสร้างหน้า:
render with name = Taylor (มาจาก console.log ใน LogAfterSet)
แปลว่า Client Component ก็ถูกรันบนเซิร์ฟเวอร์หนึ่งรอบเหมือนกัน เพื่อสร้าง HTML ชุดแรก ส่งไปให้เบราว์เซอร์เห็นภาพเร็ว ๆ ก่อน แล้ว JavaScript ค่อยตามไปทำให้มันกดได้
คำว่า 'use client' จึงไม่ได้แปลว่า "รันเฉพาะบนเบราว์เซอร์" แต่แปลว่า "ส่งโค้ดนี้ไปเบราว์เซอร์ด้วย" — ต่างกันนิดเดียวแต่คนละเรื่อง

8 · ทดสอบความเข้าใจ

ไฟล์ที่เรียก useState โดยไม่มี 'use client' ทำให้ next build พัง เพราะอะไร?

ตัวเลือกที่ 2 ถูก · ข้อความ error จริงเขียนว่า "You're importing a module that depends on useState into a React Server Component module. This API is only available in Client Components."
และสังเกตว่า npx tsc --noEmit กับไฟล์เดียวกันนี้ผ่านเงียบ ๆ เพราะเส้นแบ่ง server/client เป็นกฎของ Next.js ไม่ใช่ของ TypeScript

component มี count เป็น 0 อยู่ แล้ว handler เรียก setCount(count + 1) ติดกันสามบรรทัด · กดหนึ่งครั้งได้เลขอะไร?

ตัวเลือกที่ 2 ถูก · ผลกดจริงคือ Stale: 1 แล้วกดอีกครั้งได้ Stale: 2
ทั้งสามบรรทัดแปลได้เป็น setCount(0 + 1) เหมือนกันหมด
ถ้าอยากได้ +3 ต้องเขียน setCount((c) => c + 1) ซึ่งกดจริงแล้วได้ Updater: 3 และ Updater: 6

ใน handler เขียน setName('Robin') แล้วบรรทัดถัดไปเขียน console.log(name) · console พิมพ์อะไร?

ตัวเลือกที่ 2 ถูก · console จริงได้ name right after setName = Taylor ก่อน แล้วบรรทัดที่รันตอนวาดรอบใหม่ถึงได้ render with name = Robin
ตรงกับกล่อง Warning ในสไลด์หน้า useState() Pitfall ที่บอกว่า set function มีผลตั้งแต่ next render เป็นต้นไปเท่านั้น

ควรวาง 'use client' ตรงไหน?

ตัวเลือกที่ 1 ถูก · ผมลองวางไว้ใต้ import แล้ว build ได้ error จริงว่า The "use client" directive must be placed before other expressions. Move it to the top of the file to resolve this issue. (แถมได้ error ตัวเดิมของหัวข้อ 2 พ่วงมาด้วย รวมเป็น 2 error)
ส่วนตัวเลือกที่ 4 ทำได้จริงแต่ไม่ควรทำ เพราะจะลากทั้งหน้าไปเป็น client แล้วส่ง JavaScript ไปเบราว์เซอร์เกินจำเป็น

🛠️ ภารกิจ — ทำให้ build พังให้ได้ 2 แบบ แล้วเขียนไฟล์พิสูจน์ว่าตัวนับของคุณบวกถูก

เป้าหมายไม่ใช่ก๊อปโค้ดในบทนี้ไปวางแล้วรันผ่าน — เป้าหมายคือ สั่งให้มันพังในแบบที่คุณเลือกได้ อ่าน error ออก แล้วพิสูจน์ผลลัพธ์ด้วยเครื่อง ไม่ใช่ด้วยตา

  1. ตั้งโครง: ในโปรเจกต์ Next.js ของคุณ สร้าง app/counter/page.tsx แล้ว npx next build ให้ผ่านหนึ่งรอบก่อน จะได้รู้ว่าจุดตั้งต้นไม่พัง
  2. พังแบบที่ 1 — ลืม 'use client': เขียนปุ่มนับเลขด้วย useState โดยไม่ใส่ directive แล้วรัน npx tsc --noEmit ก่อน จากนั้นรัน npx next build
    จดผลของทั้งสองคำสั่ง — ต้องเห็นด้วยตาว่า tsc เงียบ แต่ next build ด่า
  3. พังแบบที่ 2 — วาง directive ผิดที่: ย้าย 'use client' ไปไว้ใต้บรรทัด import แล้ว build อีกครั้ง
    จดว่าคราวนี้ได้ error กี่อัน และอันแรกเขียนว่าอะไร
  4. ทำให้กับดัก +1 เกิดกับตัวเอง: เขียนปุ่มที่เรียก setCount(count + 1) สามบรรทัดติด กด 2 ครั้ง แล้วจดเลขที่เห็นจริงบนจอ
    แล้วแก้เป็น setCount((c) => c + 1) กด 2 ครั้งใหม่ · ตัวเลขต้องต่างกัน ถ้าได้เท่ากันแปลว่าแก้ไม่ติด ให้เช็คว่าเซฟไฟล์แล้วหรือยัง
  5. เขียนไฟล์พิสูจน์ (ข้อที่ห้ามข้าม): แยกตรรกะการบวกออกมาเป็นฟังก์ชันธรรมดา ที่ไม่มี React ปน เช่น lib/counter.ts:
    export const addThree = (c: number) => c + 1 + 1 + 1;
    แล้วให้ component เรียก setCount(addThree) แทน
    จากนั้นเขียน check.ts ที่ตรวจแบบนี้อย่างน้อย 4 เคส (รวมเคส 0 และเคสเลขติดลบ):
    if (addThree(0) !== 3) { throw new Error("เคส 0 พัง"); }
    คอมไพล์แล้วรันด้วย node
    เกณฑ์ผ่าน: รันแล้วเงียบสนิท และ echo $? ได้ 0 · และ npx next build ยังผ่าน
    ถ้าได้ 1 แปลว่าค่าที่คุณคาดไว้ผิด ไม่ใช่โปรแกรมผิด — กลับไปอ่านหัวข้อ 5 ใหม่

ถ้าทำครบ คุณควรตอบสี่คำถามนี้ได้โดยไม่ต้องเปิดบทเรียน:

  • ทำไม tsc ถึงไม่จับ error ของ 'use client' ให้ — เพราะเป็นกฎของ Next.js ไม่ใช่ของ TypeScript
  • ทำไม setCount(count + 1) สามครั้งได้ +1 — เพราะ count ของรอบวาดนั้นเป็นค่านิ่งค่าเดียว
  • ทำไมแยกตรรกะออกมาเป็นฟังก์ชันธรรมดาแล้วเทสง่ายขึ้น — เพราะไม่ต้องมีเบราว์เซอร์ ไม่ต้องมี React ก็รันมันได้
  • ควรติด 'use client' ที่ไฟล์ไหน ถ้าอยากส่ง JavaScript ไปเบราว์เซอร์ให้น้อยที่สุด — ที่ component เล็กที่สุดที่ต้องใช้ hook จริง ๆ ไม่ใช่ที่ layout

ติดตรงไหน ถามกลับมาได้เลย — เอา error ทั้งก้อนมาวางพร้อมบอกว่าคุณคาดว่าจะได้อะไร แล้วได้อะไรแทน จะตอบได้ตรงจุดกว่าถามว่า "ทำไมมันไม่ทำงาน" เยอะมาก

9 · สรุปสั้น ๆ ก่อนไปบทหน้า

คำถามคำตอบที่พิสูจน์แล้วในบทนี้
ทำไมแก้ let count แล้วจอไม่เปลี่ยน ตัวแปรเปลี่ยนจริง (console เห็น 1 2 3) แต่ไม่มีใครสั่งให้ React วาดใหม่ จอเลยค้างที่ 0
useState คืนอะไร array 2 ช่อง — ค่าปัจจุบันของรอบวาดนี้ กับฟังก์ชันขอเปลี่ยนค่า
ลืม 'use client' จะรู้ได้ยังไง รู้ตอน next build เท่านั้น · tsc ผ่านเงียบ ๆ ไม่เตือนเลย
setCount(count+1) สามครั้ง ได้ +1 (Stale: 1 แล้ว 2) · ต้องใช้ (c) => c + 1 ถึงได้ +3
อ่าน state ต่อจาก set ในบรรทัดถัดไป ได้ค่าเก่าเสมอ — Taylor ทั้งที่เพิ่งสั่ง setName('Robin')
มี state หลายตัวได้ไหม ได้ ไม่จำกัด แต่ละตัวแยกกันสนิท · แต่ต้องเรียกบนสุดของ component ตามลำดับเดิมทุกรอบ

บทหน้าจะเจอ hook ตัวที่สอง — useEffect — ซึ่งเอาไว้สั่งให้ทำอะไรบางอย่าง หลังหน้าวาดเสร็จ และมันมีกับดักหนักกว่าบทนี้อีก โดยเฉพาะการวนไม่รู้จบ ที่เกิดจากการเรียก setState ผิดที่ — ซึ่งจะเข้าใจได้ก็ต่อเมื่อคุณเข้าใจวงจร re-render ของบทนี้แล้ว

อ้างอิง