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

โจทย์ Customer Search — ประกอบทุกอย่างที่เรียนมาเป็นของจริง

~30 นาที · ควรผ่าน บท 73 (useState) · บท 75 (useMemo) · บท 77 (custom hooks) มาก่อน

สไลด์หน้า 56–59 ให้โจทย์มาหนึ่งข้อ ยาวบรรทัดครึ่ง: “ค้นหาลูกค้าจากชื่อ — พิมพ์ a แล้วให้คืนทุกชื่อที่มี a โดยไม่สนตัวพิมพ์เล็กใหญ่”
ฟังดูจบใน 10 นาที และมันก็จบใน 10 นาทีจริง ๆ ถ้าคุณกับคนตรวจงานเข้าใจตรงกัน
แต่บรรทัดครึ่งนั้นไม่ได้ตอบคำถามอย่างน้อย 5 ข้อ ที่คุณต้องตัดสินใจก่อนพิมพ์บรรทัดแรก — ค้นตอนพิมพ์หรือตอนกดปุ่ม · เว้นวรรคนับไหม · ไม่เจอแล้วโชว์อะไร · และทุกคำตอบที่คุณเลือก โค้ดจะไม่ error สักตัว
บทนี้จึงทำสามอย่าง: แตกโจทย์เป็นภารกิจย่อยให้ลองเขียนเอง (มีปุ่มเฉลย) · ชี้จุดที่ต้องตัดสินใจเอง · แล้วปิดท้ายด้วย recap ของทั้งกลุ่ม Next.js บท 69–78 ตามสไลด์หน้า 65–71

🧰 ของที่ใช้ตรวจในบทนี้

Next.js  v16.3.4       # npx next --version
react    19.2.8        # require('react/package.json').version
tailwind 4.3.3
tsc      5.9.3
node     v24.14.0

โค้ดทุกก้อนในบทนี้อยู่ในโปรเจกต์ Next.js จริง · เฉลยทุกข้อผ่าน npx next build · npx tsc --noEmit · npx eslint ครบทั้งสามตัว
ส่วนตัวอย่างที่ตั้งใจให้ผิด (ลืม "use client" · ลืม key) ก็รันจริงเหมือนกัน เพื่อเอาข้อความ error ตัวจริงมาโชว์
ผลลัพธ์บนหน้าเว็บได้จากการรัน npx next dev แล้ว curl เอา HTML จริงมาดู · ตัวเลขจำนวนคนที่ค้นเจอได้จากการรันตรรกะกรองชุดเดียวกันด้วย node
ทุกข้อความ error และทุกตัวเลขในบทนี้ก็อปมาจากหน้าจอจริง ไม่ได้เขียนจากความจำ

เรื่องชื่อโฟลเดอร์: ในภารกิจผมให้คุณสร้างไฟล์ที่ components/CustomerList/ กับ app/customers/ ซึ่งเป็นชื่อที่อ่านรู้เรื่อง
แต่โปรเจกต์ที่ผมใช้ตรวจเป็นโปรเจกต์เดียวที่แชร์กันทุกบท ไฟล์ของบทนี้จึงอยู่ที่ components/t78/ กับ app/t78/ — เพราะงั้นเวลาเห็น t78 โผล่ในข้อความ error หรือในตาราง build ให้อ่านว่าเป็นโฟลเดอร์ของบทนี้ เนื้อโค้ดข้างในเหมือนกันทุกตัวอักษร

⚠️ ข้อมูลตั้งต้นในบทนี้มี 25 คน ไม่ใช่ครบตามสไลด์

สไลด์หน้า 58 โชว์การ์ดลูกค้าเป็นตาราง แต่ขอบล่างของสไลด์ตัดแถวสุดท้ายทิ้ง — แถวที่ 4 เห็นแต่ชื่อ (Chloe Andersson · Henry Osei · Zoe Ibrahim · Samuel Rivera · Layla Haddad · Jack Sullivan) อายุถูกตัดหายไปทั้งแถว
อายุของสี่คนในนั้น บวกอีกสามคนที่หน้า 58 ไม่ทันโชว์ (Nora Lindqvist · Aria Costa · David Mensah) ไปโผล่ที่สไลด์หน้า 59 ซึ่งเป็นภาพผลค้นคำว่า s
รวมสองหน้าแล้วผมอ่านชื่อได้ 27 คน แต่มีสองคน (Zoe Ibrahim · Layla Haddad) ที่เห็นแค่ชื่อ ไม่เห็นอายุเลยทั้งสองหน้า
ผมจึงตัดสองคนนั้นออก แทนที่จะเดาอายุให้ — ไฟล์ข้อมูลในบทนี้จึงมี 25 คน
ข้อดีคือมันตรวจสอบตัวเองได้: สไลด์หน้า 59 โชว์ผลของการค้นคำว่า s ไว้ 16 คน และข้อมูล 25 คนของผมค้นคำว่า s ก็ได้ 16 คน ชื่อตรงกันทุกตัว เรียงลำดับเดียวกัน (สองคนที่ตัดออกไม่มีตัว s ในชื่อ จึงไม่กระทบผลเลย)

โจทย์จากสไลด์ — ทั้งหมดที่มี

สไลด์หน้า 57 เขียนไว้แค่นี้:

Search for customers by name. For example, search for "a"; return every name including "a", case-insensitive: "Ava Thompson", "Liam Carter", ...

พร้อมข้อมูลตั้งต้นเป็นไฟล์ JSON หน้าตาแบบนี้ (สไลด์โชว์ 5 บรรทัดแรก ที่เหลือผมอ่านจากการ์ดหน้า 58–59):

// components/CustomerList/customers.json
[
  { "id": "c001", "name": "Ava Thompson",     "age": 29 },
  { "id": "c002", "name": "Liam Carter",      "age": 34 },
  { "id": "c003", "name": "Sophia Martinez",  "age": 41 },
  { "id": "c004", "name": "Noah Bennett",     "age": 23 },
  { "id": "c005", "name": "Isabella Rossi",   "age": 37 },
  // … c006 Mason Clarke 52 · c007 Mia Nguyen 19 · c008 Ethan Walker 45
  // … c009 Charlotte Dubois 31 · c010 Lucas Ferreira 27 · c011 Amelia Chen 38
  // … c012 James O'Connor 60 · c013 Harper Kim 24 · c014 Benjamin Novak 33
  // … c015 Ella Fischer 47 · c016 Alexander Petrov 55 · c017 Grace Okafor 22
  // … c018 Daniel Suzuki 40 · c019 Chloe Andersson 30 · c020 Henry Osei 26
  // … c021 Samuel Rivera 48 · c022 Jack Sullivan 43 · c023 Nora Lindqvist 28
  // … c024 Aria Costa 25 · c025 David Mensah 50
]

หน้าตาที่ต้องได้ (สไลด์หน้า 58): ช่องพิมพ์หนึ่งช่องอยู่บนสุด แล้วข้างล่างเป็น ตารางการ์ด 6 คอลัมน์ แต่ละการ์ดมีชื่อกับอายุ

┌────────────────────┐ │ │ ← ช่องค้นหา (input) └────────────────────┘ ┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐┌──────────┐ │Ava ││Liam ││Sophia ││Noah ││Isabella ││Mason │ │Thompson ││Carter ││Martinez ││Bennett ││Rossi ││Clarke │ │age: 29 ││age: 34 ││age: 41 ││age: 23 ││age: 37 ││age: 52 │ └──────────┘└──────────┘└──────────┘└──────────┘└──────────┘└──────────┘ ┌──────────┐┌──────────┐┌──────────┐ … อีก 19 คน …

ก่อนพิมพ์บรรทัดแรก — 5 คำถามที่โจทย์ไม่ได้ตอบ

นี่คือส่วนที่สำคัญที่สุดของบทนี้ อ่านให้จบก่อนเปิดเอดิเตอร์
จดคำตอบของคุณลงกระดาษก่อน แล้วค่อยไปดูว่าโค้ดในสไลด์เลือกทางไหน — ถ้าคุณเลือกต่างจากสไลด์ นั่นไม่ผิด แต่คุณต้องรู้ตัวว่าเลือก

คำถามทางเลือกที่เป็นไปได้โค้ดในสไลด์เลือกทางไหน
1 · ค้นตอนไหน กรองทุกครั้งที่พิมพ์ตัวอักษร · หรือรอกดปุ่ม “ค้นหา” / กด Enter กรองทันทีที่พิมพ์ (onChange) — ไม่มีปุ่มเลย
2 · ตัวพิมพ์เล็กใหญ่ นับ · ไม่นับ ไม่นับ (โจทย์บอกตรง ๆ ว่า case-insensitive)
3 · ช่องว่างหัวท้าย ตัดทิ้งก่อนค้น (trim) · เอาไปค้นตรง ๆ เอาไปค้นตรง ๆ — ไม่มี trim
4 · ค้นไม่เจอ โชว์ข้อความบอก · โชว์พื้นที่ว่าง ๆ · โชว์ทุกคนเหมือนเดิม พื้นที่ว่าง ไม่มีข้อความอะไรเลย
5 · ค้นจากอะไร เฉพาะชื่อ · ชื่อ+รหัส · ชื่อ+รหัส+อายุ เฉพาะ name เท่านั้น
🍜 เทียบกับของจริง

โจทย์บรรทัดครึ่งนี้เหมือนเดินเข้าร้านแล้วสั่งว่า “ขอข้าวผัดจานนึง”
ร้านทำให้ได้แน่ ๆ และคุณก็ได้ข้าวผัดจริง ๆ แต่คนทำต้องตัดสินใจแทนคุณอีกสิบอย่าง: ใส่ไข่ไหม · เผ็ดแค่ไหน · หมูหรือไก่ · มาพร้อมมะนาวไหม
คุณไม่ได้พูดสักคำ แต่พอจานมาถึงแล้วไม่ถูกใจ คุณจะรู้สึกว่า “ร้านทำผิด” ทั้งที่ร้านไม่ได้ทำผิดอะไรเลย — คุณแค่ไม่ได้บอก

ที่ไม่เหมือนกัน: ร้านข้าวผัดถามกลับได้ทันที และคุณชิมคำแรกก็รู้เลยว่าไม่ตรง
แต่ซอฟต์แวร์ “ชิมไม่ออก” — โปรแกรมที่ค้นตอนพิมพ์กับโปรแกรมที่ค้นตอนกดปุ่ม หน้าตาบนสกรีนช็อตเหมือนกันเป๊ะ ต่างกันตอนใช้จริงเท่านั้น และไม่มี compiler ตัวไหนบนโลกฟ้องให้ · นี่คือเหตุผลที่ต้องถามก่อน ไม่ใช่ถามหลังส่งงาน

ภารกิจ 1 — วางลูกค้าทั้ง 25 คนลงหน้าเว็บก่อน (ยังไม่ต้องค้น)

อย่าเพิ่งเขียนช่องค้นหา · งานแรกคือทำให้ข้อมูลโผล่บนจอให้ได้ก่อน ถ้าขั้นนี้ยังไม่ผ่าน การค้นก็ไม่มีอะไรให้ค้น

🎯 ภารกิจข้อ 1

  1. สร้าง components/CustomerList/customers.json วางข้อมูล 25 คนลงไป
  2. สร้าง components/CustomerList/CustomerList.tsx ที่ import ไฟล์ JSON แล้ววาดการ์ดทุกใบด้วย .map()
  3. สร้าง app/customers/page.tsx ที่เรียก <CustomerList />
  4. รัน npx next build ให้ผ่าน แล้วเปิด npx next dev ดูว่ามีการ์ด 25 ใบจริง
// components/CustomerList/CustomerList.tsx
import customers from "./customers.json";

const CustomerList = () => {
  return (
    <div className="grid grid-cols-6 gap-2">
      {customers.map((customer) => (
        <div key={customer.id} className="border p-2 text-center">
          <p>{customer.name}</p>
          <p>age: {customer.age}</p>
        </div>
      ))}
    </div>
  );
};

export default CustomerList;

สามอย่างที่ต้องสังเกต:

  • import customers from "./customers.json" ใช้ได้เลยเพราะ tsconfig.json ที่ create-next-app สร้างให้ เปิด "resolveJsonModule": true ไว้อยู่แล้ว — TypeScript อ่านไฟล์ JSON แล้วเดา type ให้เองด้วย ไม่ต้องประกาศ type Customer ก็ได้
  • key={customer.id} — ห้ามลืม (predict ข้างล่างจะโชว์ให้ดูว่าลืมแล้วเกิดอะไร)
  • ยังไม่ต้องใส่ "use client" เพราะขั้นนี้ยังไม่มี hook สักตัว

ผลรันจริง — เปิด npx next dev -p 378 แล้ว curl เอา HTML มานับ:

$ curl -s http://localhost:378/t78 | grep -o 'age: [0-9]*' | wc -l
      25
// ลืมใส่ key จะเกิดอะไร? โค้ดนี้ build ผ่านไหม
{customers.map((customer) => (
  <p>{customer.name}</p>
))}

คิดว่า build พังไหม แล้วจะเห็นอะไร?

next build ผ่านสบาย ๆ · หน้าเว็บแสดงผลปกติครบทุกชื่อ — ผมเอาโค้ดนี้ใส่เข้าไปแล้ว build จริง มันขึ้นเป็น ○ /t78/nokey ในตารางเหมือนหน้าอื่น
แต่ terminal ที่รัน next dev พ่นข้อความนี้ออกมาทุกครั้งที่โหลดหน้า:

Each child in a list should have a unique "key" prop.

Check the top-level render call using <p>. See https://react.dev/link/warning-keys for more information.

แล้วมีอีกตัวที่จับได้ — eslint ที่ติดมากับ create-next-app ถือว่าเรื่องนี้เป็น error ไม่ใช่ warning:

$ npx eslint app/t78/nokey/page.tsx

app/t78/nokey/page.tsx
  7:9  error  Missing "key" prop for element in iterator  react/jsx-key

✖ 1 problem (1 error, 0 warnings)

นี่คือกับดักที่เจ็บที่สุดของ React สำหรับคนที่มาจาก Go — compiler ปล่อยผ่าน หน้าเว็บก็ดูปกติ ตัวที่ฟ้องคือ linter กับ console ซึ่งเป็นสองที่ที่คนมองข้ามบ่อยที่สุด
แต่พอรายการเริ่มเปลี่ยนลำดับหรือถูกกรอง (ซึ่งคือทั้งหมดที่เรากำลังจะทำในภารกิจข้อ 2) React จะจับคู่ของเก่ากับของใหม่ผิด — อาการที่เจอบ่อยคือ พิมพ์ในช่อง input ของการ์ดใบหนึ่ง แล้วข้อความไปโผล่อีกใบ
ในโจทย์นี้เรามี id ให้ใช้อยู่แล้ว ใช้ id อย่าใช้ index
บทเรียนที่ใหญ่กว่านั้น: “build ผ่าน” ไม่ได้แปลว่า “ถูก” — รัน npx eslint ด้วยทุกครั้ง ไม่ใช่แค่ next build

ภารกิจ 2 — เพิ่มช่องค้นหา แล้วเจอด่านแรกของ App Router

ตอนนี้ค่อยเติมช่องค้นหา · ต้องใช้ useState เก็บคำค้น และ useMemo จำผลกรองไว้ตามที่สไลด์ทำ

// components/CustomerList/CustomerList.tsx — บรรทัดบนสุด
import { useMemo, useState, type ChangeEvent } from "react";
import customers from "./customers.json";
// … แล้วใช้ useState ข้างใน component ตามปกติ

เขียนแค่นี้แล้วสั่ง npx next build — ผ่านไหม?

ไม่ผ่าน · นี่คือ output จริงจาก npx next build บน Next.js 16.3.4:

▲ Next.js 16.3.4 (Turbopack)
✓ Running next.config.ts took 9ms

  Creating an optimized production build ...

> Build error occurred
Error: Turbopack build failed with 1 error:
./components/t78/CustomerList.tsx:1:19
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 { useMemo, useState, type ChangeEvent } from "react";
    |                   ^^^^^^^^
  2 | import customers from "./customers.json";
  3 |
  4 | const CustomerList = () => {

Ecmascript file had an error

Import trace:
  Server Component:
    ./components/t78/CustomerList.tsx
    ./app/t78/page.tsx

App Router ถือว่าทุกไฟล์เป็น Server Component โดยปริยาย — คุณไม่ได้เขียนอะไรผิด แต่คุณไม่ได้เขียนอะไรบางอย่างที่ต้องเขียน
วิธีแก้คือใส่บรรทัดนี้เป็นบรรทัดแรกสุดของไฟล์ (ก่อน import ทุกตัว):

"use client";

import { useMemo, useState, type ChangeEvent } from "react";

สังเกต Import trace ท้าย error — Next.js บอกเส้นทางให้ครบว่า ไฟล์ที่ผิดคือ CustomerList.tsx และมันถูกลากเข้ามาโดย page.tsx
ใส่ "use client" ที่ CustomerList.tsx พอ ไม่ต้องไปใส่ที่ page.tsx — ยิ่งใส่ไว้ใกล้ปลายทางเท่าไหร่ ส่วนที่ยังรันบน server ก็ยิ่งเหลือมาก

📦 เทียบกับของจริง

"use client" คือสติกเกอร์ที่แปะหน้ากล่องพัสดุว่า “เปิดที่ปลายทางเท่านั้น”
ของในโรงงาน (server) แกะดูได้ ตรวจได้ ห่อเสร็จส่งเป็นกล่องสำเร็จรูปไปเลย แต่ของบางอย่าง — อะไรที่ต้องตอบสนองคนที่ถือมันอยู่ เช่น ช่องพิมพ์ ปุ่มกด — ต้องไปแกะที่บ้านลูกค้า (browser) ถึงจะทำงานได้

ที่ไม่เหมือนกัน: สติกเกอร์บนกล่องจริงติดผิดแล้วของยังส่งถึง แค่คนแกะงง
แต่ "use client" ลืมแปะแล้ว build ไม่ผ่านเลย — ซึ่งเป็นข่าวดี
และที่ต่างกว่านั้น: สติกเกอร์นี้ติดเชื้อลงล่าง — ไฟล์ที่ติด "use client" จะลาก component ทุกตัวที่มัน import ให้กลายเป็นฝั่ง client ตามไปด้วยหมด · เพราะงั้นอย่าแปะไว้บนสุดของต้นไม้ แปะให้ใกล้ใบที่ต้องการมันจริง ๆ

🎯 ภารกิจข้อ 2

  1. ใส่ "use client" บรรทัดแรกของ CustomerList.tsx
  2. เก็บคำค้นด้วย useState("")
  3. เขียนฟังก์ชันกรอง: ถ้าคำค้นเป็นค่าว่างให้คืนทุกคน · ถ้าไม่ว่างให้กรองด้วย includes แบบไม่สนตัวพิมพ์เล็กใหญ่
  4. ห่อผลกรองด้วย useMemo ที่ผูกกับคำค้น
  5. ผูก <input> เข้ากับ state (value + onChange) โดยพิมพ์ type ของ event ให้ถูกต้อง
  6. รัน npx next build และ npx tsc --noEmit ให้ผ่านทั้งคู่

นี่คือโค้ดตามสไลด์หน้า 59 (สไลด์โชว์ถึงบรรทัด 33 ส่วน JSX ที่เหลือผมเขียนต่อให้ตรงกับภาพหน้า 58):

"use client";

import { useMemo, useState, type ChangeEvent } from "react";
import customers from "./customers.json";

const CustomerList = () => {
  const [search, setSearch] = useState("");

  const filterCustomer = (search: string) => {
    if (search !== "") {
      const searchValue = search.toLocaleLowerCase();
      return customers.filter((customer) => {
        const customerName = customer.name.toLocaleLowerCase();
        return customerName.includes(searchValue);
      });
    }
    return [...customers];
  };

  const customerData = useMemo(() => {
    return filterCustomer(search);
  }, [search]);

  const handleSearch = (event: ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value;
    setSearch(value);
  };

  return (
    <>
      <input
        type={"text"}
        className={"border"}
        value={search}
        onChange={handleSearch}
      />
      <div className="grid grid-cols-6 gap-2">
        {customerData.map((customer) => (
          <div key={customer.id} className="border p-2 text-center">
            <p>{customer.name}</p>
            <p>age: {customer.age}</p>
          </div>
        ))}
      </div>
    </>
  );
};

export default CustomerList;

ผลรันจริงจากตรรกะกรองชุดเดียวกันนี้ (รันด้วย node บนข้อมูล 25 คน):

$ node probe.mjs
search=""          ->  25 คน
search="s"         ->  16 คน
   Ava Thompson, Sophia Martinez, Isabella Rossi, Mason Clarke,
   Charlotte Dubois, Lucas Ferreira, James O'Connor, Ella Fischer,
   Daniel Suzuki, Chloe Andersson, Henry Osei, Samuel Rivera,
   Jack Sullivan, Nora Lindqvist, Aria Costa, David Mensah
search="MARTINEZ"  ->  1 คน
   Sophia Martinez
search="zz"        ->  0 คน

บรรทัด search="s" คือบรรทัดที่ยืนยันว่าเราทำถูก — ตรงกับสไลด์หน้า 59 ทั้งจำนวนและลำดับ
และบรรทัด search="MARTINEZ" คือบรรทัดที่พิสูจน์ว่า case-insensitive ทำงานจริง ไม่ใช่แค่เขียนไว้เฉย ๆ

สามจุดในเฉลยที่ควรหยุดถาม

1 · ทำไมต้อง [...customers] ไม่ใช่ customers เฉย ๆ

บรรทัด return [...customers]; คืนสำเนาใหม่ของกองข้อมูล ไม่ใช่ตัวเดิม
ในโค้ดชุดนี้มันยังไม่จำเป็น เพราะเราแค่เอาไป .map() อ่านอย่างเดียว
แต่มันเป็นนิสัยที่ถูก: ถ้าวันหนึ่งมีใครเติม .sort() ต่อท้าย customers ตัวจริงที่ import มาจาก JSON จะโดนเรียงใหม่ถาวรทั้งไฟล์ และมันจะไม่กลับมาเป็นลำดับเดิมจนกว่าจะรีเฟรชหน้า (sort แก้ของเดิม — เรื่องนี้อยู่ใน บท 68 ข้อ 2)

2 · toLocaleLowerCase() ต่างจาก toLowerCase() ยังไง

สไลด์ใช้ toLocaleLowerCase() · คนส่วนใหญ่เขียน toLowerCase() · ผลรันจริงบน node v24.14.0 บนเครื่องนี้:

$ node -e "const s='ISABELLA';
console.log('default  toLocaleLowerCase:', s.toLocaleLowerCase());
console.log('toLowerCase             :', s.toLowerCase());
console.log('tr-TR    toLocaleLowerCase:', s.toLocaleLowerCase('tr-TR'));
console.log('Intl default locale      :', Intl.DateTimeFormat().resolvedOptions().locale);"
default  toLocaleLowerCase: isabella
toLowerCase             : isabella
tr-TR    toLocaleLowerCase: ısabella
Intl default locale      : en-US

สองบรรทัดแรกได้เท่ากัน — บนเครื่องนี้ สองอันนี้ไม่ต่างกันเลย
บรรทัดที่สามคือเหตุผลที่มันมีอยู่: ถ้า locale เป็นตุรกี ตัว I ใหญ่ จะกลายเป็น ı (ไม่มีจุด) ไม่ใช่ i — แปลว่า “Isabella” จะหาไม่เจอถ้าผู้ใช้พิมพ์ “i”

⚠️ อย่าจำว่า “toLocaleLowerCase ดีกว่า”

สำหรับการค้นหา คนส่วนใหญ่แนะนำให้ใช้ toLowerCase() ต่างหาก เพราะมันให้ผลเหมือนกันทุกเครื่องทุก locale — คุณจะได้ไม่เจอบั๊กที่ “ทำงานปกติบนเครื่องผม แต่พังบนเครื่องลูกค้าที่ตั้งภาษาตุรกี”
toLocaleLowerCase() เหมาะกับตอนแสดงผลให้คนอ่าน มากกว่าตอนเปรียบเทียบ
ในบทนี้ผมคงไว้ตามสไลด์ทุกตัวอักษร เพื่อให้เทียบกับสไลด์ได้ตรง ๆ แต่ให้รู้ไว้ว่ามีจุดนี้อยู่

3 · useMemo ตรงนี้จำเป็นจริงไหม

ตอบตรง ๆ: ในโจทย์ขนาดนี้ ไม่จำเป็น เขียน const customerData = filterCustomer(search); เฉย ๆ ก็ได้ผลเหมือนกัน และไม่มีใครวัดความต่างได้กับข้อมูล 25 แถว

useMemo เริ่มคุ้มเมื่อสองเงื่อนไขนี้เกิดพร้อมกัน: (ก) การคำนวณแพงจริง (กรองข้อมูลหลักหมื่นแถว) หรือ (ข) ผลลัพธ์ถูกส่งเป็น prop ให้ component ลูกที่ห่อด้วย React.memo ซึ่งจะ re-render ทุกครั้งถ้าได้ array ตัวใหม่

เช็คของจริงก่อนเถียง: next.config.ts ของโปรเจกต์นี้ เป็นค่าเริ่มต้นล้วน ๆ ไม่ได้เปิด React Compiler ไว้ — แปลว่าบนโปรเจกต์นี้ useMemo / useCallback ยังต้องเขียนเองอยู่ ไม่มีตัวช่วยใส่ให้อัตโนมัติ (รายละเอียดอยู่ใน บท 75)

// ผู้ใช้เผลอเคาะ space bar หนึ่งครั้งในช่องค้นหา
// search === " "   (เว้นวรรคหนึ่งตัว ไม่ใช่ค่าว่าง)
filterCustomer(" ")

ได้ลูกค้ากี่คน จาก 25 คน?

ผลรันจริง:

search=" "  ->  25 คน

ได้ครบ 25 คน — แต่ด้วยเหตุผลที่ผิด
โค้ดไม่ได้มองว่า " " เท่ากับค่าว่าง (" " !== "" เป็นจริง จึงเข้าเงื่อนไข if ไปกรองจริง ๆ)
มันกรองจริง แล้วบังเอิญทุกชื่อในไฟล์มีเว้นวรรคคั่นระหว่างชื่อกับนามสกุล → "ava thompson".includes(" ") เป็นจริงทุกคน

ลองจินตนาการว่าถ้าข้อมูลจริงมีลูกค้าชื่อเดี่ยว ๆ ไม่มีนามสกุล — คนคนนั้นจะหายไปจากหน้าจอเพราะผู้ใช้เผลอเคาะ space แล้วไม่มีอะไรบอกเลยว่าเกิดอะไรขึ้น
นี่คือเหตุผลที่คำถามข้อ 3 ในตารางข้างบน (“ช่องว่างหัวท้ายนับไหม”) ไม่ใช่เรื่องจุกจิก

ภารกิจ 3 — เขียนเวอร์ชันที่ “ตัดสินใจแล้ว”

ภารกิจนี้คือหัวใจของบทเรียน · ไม่ได้ให้ลอกสไลด์ แต่ให้เลือกทางที่ต่างจากสไลด์อย่างตั้งใจ แล้วดูว่าโค้ดเปลี่ยนตรงไหนบ้าง

🎯 ภารกิจข้อ 3

เขียน CustomerSearchV2 ที่ตัดสินใจสามข้อนี้ต่างจากสไลด์:

  1. ค้นตอนกดปุ่ม / กด Enter ไม่ใช่ตอนพิมพ์ — แปลว่าต้องมี state สองตัว: ตัวที่เก็บสิ่งที่กำลังพิมพ์ กับตัวที่เก็บสิ่งที่ค้นไปแล้ว
  2. trim() ก่อนค้น — เคาะ space แล้วต้องเท่ากับไม่ได้พิมพ์อะไร
  3. ค้นไม่เจอต้องมีข้อความบอก พร้อมบอกจำนวนที่เจอเสมอ
  4. รัน npx next build · npx tsc --noEmit · npx eslint ให้ผ่านทั้งสามตัว
"use client";

import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import customers from "./customers.json";

type Customer = { id: string; name: string; age: number };

function filterByName(list: Customer[], keyword: string): Customer[] {
  const needle = keyword.trim().toLocaleLowerCase();
  if (needle === "") return list;
  return list.filter((c) => c.name.toLocaleLowerCase().includes(needle));
}

export default function CustomerSearchV2() {
  const [draft, setDraft] = useState("");   // สิ่งที่กำลังพิมพ์
  const [query, setQuery] = useState("");   // สิ่งที่กดค้นไปแล้ว

  const results = useMemo(() => filterByName(customers, query), [query]);

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setDraft(event.target.value);
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();      // กัน browser รีโหลดหน้าทิ้ง state
    setQuery(draft);
  };

  return (
    <section>
      <form onSubmit={handleSubmit}>
        <input type="text" value={draft} onChange={handleChange}
               placeholder="พิมพ์ชื่อลูกค้า" className="border px-2 py-1" />
        <button type="submit" className="border px-3 py-1">ค้นหา</button>
      </form>

      <p>พบ {results.length} คน</p>

      {results.length === 0 ? (
        <p>ไม่พบลูกค้าที่ชื่อมีคำว่า &quot;{query.trim()}&quot;</p>
      ) : (
        <ul>
          {results.map((customer) => (
            <li key={customer.id}>{customer.name} — age: {customer.age}</li>
          ))}
        </ul>
      )}
    </section>
  );
}

ผลตรวจจริงทั้งสามตัว:

$ npx next build
├ ○ /t78
├ ○ /t78/v2
└ ○ /t78/v2-empty

$ npx tsc --noEmit; echo "rc=$?"
rc=0

$ npx eslint components/t78 app/t78; echo "rc=$?"
rc=0

(tsc กับ eslint ที่ผ่านจะไม่พิมพ์อะไรเลย ต้องดู exit code เอง — เพราะงั้นถึงต้องต่อ echo "rc=$?" ให้มันบอกออกมา)

และ HTML จริงของหน้าเวอร์ชัน 2 ตอนที่คำค้นว่าง กับตอนที่ค้นคำว่า zz (/t78/v2-empty คือหน้าเดียวกันที่ผมตั้งคำค้นเริ่มต้นเป็น zz ไว้ให้ตรวจง่าย ๆ):

$ curl -s http://localhost:378/t78/v2 | grep -o '<li[ >]' | wc -l
      25

$ curl -s http://localhost:378/t78/v2-empty | grep -o '<li[ >]' | wc -l
       0

$ curl -s http://localhost:378/t78/v2-empty \
    | python3 -c "import sys,re; print('\n'.join(re.findall(r'<p>.*?</p>', sys.stdin.read())))"
<p>พบ <!-- -->0<!-- --> คน</p>
<p>ไม่พบลูกค้าที่ชื่อมีคำว่า &quot;<!-- -->zz<!-- -->&quot;</p>

(<!-- --> ที่โผล่ใน HTML คือเครื่องหมายที่ React แทรกเองเพื่อบอกขอบเขตของค่าที่มาจาก JavaScript ไม่ใช่ของที่เราเขียน — เห็นแบบนี้ถือว่าปกติ)

ค้นตอนพิมพ์ — แบบสไลด์

  • state ตัวเดียว · โค้ดสั้นกว่า
  • รู้สึกไวมาก เห็นผลทันที
  • ข้อมูล 25 แถวในเครื่อง — เหมาะสุด
  • ⚠️ ถ้าข้อมูลอยู่หลัง API จะยิงคำขอทุกตัวอักษรที่พิมพ์ พิมพ์ martinez = ยิง 8 ครั้ง

ค้นตอนกดปุ่ม — แบบ V2

  • state สองตัว · โค้ดยาวกว่า
  • ผู้ใช้ต้องกดเพิ่มหนึ่งครั้ง
  • ยิง API ครั้งเดียวต่อการค้นหนึ่งครั้ง
  • ✅ เข้าถึงด้วยคีย์บอร์ดได้ตามธรรมชาติ เพราะเป็น <form> จริง กด Enter ก็ค้นได้

ทางที่สามที่ใช้กันเยอะที่สุดในของจริงคือ ค้นตอนพิมพ์แต่หน่วงไว้ — รอให้ผู้ใช้หยุดพิมพ์สัก 300–500 มิลลิวินาทีค่อยยิงค้นทีเดียว ซึ่งคือ useDebounce ที่สไลด์หน้า 71 พูดถึงพอดี (เขียนเองได้ตาม บท 77)

🗣 ศัพท์ที่เพิ่งเรียนไป
Client Component / Server Component
ใน App Router ทุกไฟล์เป็น Server Component จนกว่าจะแปะ "use client" · Server Component แตะ useState/useEffect ไม่ได้เลย แลกกับการที่มันไม่ต้องส่ง JavaScript ไปให้ browser · ถาม senior ได้ว่า “อันนี้ต้องเป็น client component จริงไหม หรือดันขอบเขตลงไปได้อีก” คำถามนี้คือคำถามที่ทำให้เว็บเบาลงจริง ๆ
directive ("use client", "use server")
สตริงเปล่า ๆ ที่วางเป็นบรรทัดแรกสุดของไฟล์ แล้วมีความหมายพิเศษกับตัวแปลโค้ด · มันไม่ใช่ import ไม่ใช่ฟังก์ชัน · วางผิดตำแหน่ง (เช่นหลัง import) เท่ากับไม่ได้วาง
prerender / hydration
Next.js สร้าง HTML ชุดแรกไว้ตั้งแต่ตอน build (prerender) แล้วพอถึง browser ค่อยเอา JavaScript มา “เสียบปลั๊ก” ให้กดได้พิมพ์ได้ (hydration) · เพราะงั้นหน้า /t78 ถึงขึ้น ○ (Static) ในตาราง build ทั้งที่ข้างในเป็น client component — 25 การ์ดอยู่ใน HTML ตั้งแต่วินาทีแรก ผู้ใช้เห็นก่อน JS โหลดเสร็จด้วยซ้ำ
controlled input
<input> ที่ค่าถูกบังคับด้วย state (value={search}) แทนที่จะให้ browser จำเอง · ถ้าใส่ value แล้วลืม onChange ช่องจะพิมพ์ไม่ได้เลย และ React จะเตือนใน console · บอก AI ได้ว่า “make this a controlled input”
🗣 ศัพท์สำหรับคุยเรื่องโจทย์ ไม่ใช่เรื่องโค้ด
derived state (สถานะที่คำนวณออกมา)
ผลกรอง customerData ไม่ใช่ state — มันคำนวณจาก search ได้เสมอ · กฎง่าย ๆ: อะไรที่คำนวณจาก state อื่นได้ อย่าเก็บเป็น state ซ้ำ เพราะคุณจะต้องคอยจำอัปเดตให้มันสองที่ แล้ววันหนึ่งจะลืม · ถาม senior ว่า “ตัวนี้ควรเป็น state หรือเป็น derived”
empty state
หน้าจอตอนที่ “ไม่มีอะไรให้แสดง” — ค้นไม่เจอ · ยังไม่มีข้อมูล · โหลดไม่สำเร็จ · เป็นสิ่งที่โจทย์แทบไม่เคยเขียนถึง และเป็นสิ่งที่คนตรวจงานสังเกตเป็นอย่างแรก · ถามคนออกโจทย์ตรง ๆ ว่า “empty state ให้แสดงอะไร”
debounce
หน่วงไม่ให้ทำงานทุกครั้งที่มีเหตุการณ์ รอจนเงียบครบเวลาที่กำหนดค่อยทำทีเดียว · คู่กับคำว่า throttle (จำกัดว่าทำได้ไม่เกิน N ครั้งต่อวินาที) · พิมพ์บอก AI ว่า “debounce the search input by 300ms” ได้เลย
acceptance criteria
รายการเงื่อนไขที่ตกลงกันไว้ว่า “แบบไหนถึงเรียกว่าเสร็จ” · ตารางคำถาม 5 ข้อข้างบนคือการเปลี่ยนโจทย์บรรทัดครึ่งให้กลายเป็น acceptance criteria · ประโยคที่ใช้ได้จริงในที่ทำงาน: “ขอ acceptance criteria ของเคสค้นไม่เจอหน่อยครับ”

Recap ทั้งกลุ่ม — บท 69 ถึง 78

สไลด์หน้า 65–71 สรุปทั้งคอร์สไว้ห้าหัวข้อ · ตารางนี้จับคู่หัวข้อในสไลด์กับบทเรียนที่ผ่านมา

บทเรื่องสิ่งเดียวที่ต้องจำให้ได้
69 Next.js คืออะไร React คือตัววาด UI · Next.js เติม routing จากโฟลเดอร์ · การเรนเดอร์ฝั่ง server · และ bundler ให้ฟรี
70 ติดตั้ง + โครงโฟลเดอร์ app/layout.tsx ห่อทุกหน้า · app/<ชื่อ>/page.tsx คือหนึ่ง URL · ชื่อโฟลเดอร์คือเส้นทาง
71 component + props component คือฟังก์ชันที่คืน JSX · props คือพารามิเตอร์ที่มี type · children คือของที่ยัดไว้ระหว่างแท็ก
72 Tailwind CSS เขียนสไตล์เป็น class สำเร็จรูปในที่เดียวกับ markup · ในโปรเจกต์นี้เป็น v4 ไม่มี tailwind.config.js
73 useState เปลี่ยน state = สั่งให้ component วาดใหม่ · แก้ตัวแปรธรรมดาไม่ทำให้จอเปลี่ยน
74 useEffect ทำงานหลังวาดเสร็จ · dependency array คุมว่าให้ทำซ้ำเมื่อไหร่ · ลืมใส่แล้ววนไม่รู้จบ
75 useMemo / useCallback จำค่า vs จำฟังก์ชัน · ใส่เมื่อมีเหตุผล ไม่ใช่ใส่ไว้ก่อน
76 useContext แก้ปัญหาส่ง prop ผ่านหลายชั้น · Provider ครอบไว้ตรงไหน ลูกใต้ตรงนั้นหยิบใช้ได้หมด
77 custom hooks ขึ้นต้นด้วย use · เรียก hook อื่นอย่างน้อยหนึ่งตัว · เรียกที่ระดับบนสุด · แต่ละที่ที่เรียกได้ state แยกกัน
78 โจทย์ Customer Search โจทย์ที่อ่านแล้ว “เข้าใจเลย” คือโจทย์ที่ยังไม่ได้ถาม · ตัดสินใจให้ครบก่อนพิมพ์

สามประโยคจากสไลด์ที่ควรจำเป็นคำ ๆ

สไลด์ 66 · Component Function props (typed parameters) ─┐ state (internal variables) ├─→ ฟังก์ชันหนึ่งตัว ─→ JSX.Element (Rendered UI) events (callbacks) ─┘ สไลด์ 68 · "Everything is component" สไลด์ 70 · A Cycle of State Management 1 initial state → 2 user action → 3 update state ↑ ↓ 6 state คือความจริง ← 5 UI วาดใหม่ ← 4 React ประมวลผล

วงจร 6 ขั้นในสไลด์หน้า 70 คือคำอธิบายที่ตรงที่สุดของสิ่งที่เกิดขึ้นใน Customer Search ของเรา:
ผู้ใช้พิมพ์ (ขั้น 2) → handleSearch เรียก setSearch (ขั้น 3) → React จัดคิววาดใหม่ (ขั้น 4) → useMemo คำนวณผลกรองรอบใหม่แล้ววาดการ์ด (ขั้น 5) → search ตัวใหม่กลายเป็นความจริงของรอบถัดไป (ขั้น 6)
ไม่มีบรรทัดไหนในโค้ดเราที่สั่งให้ DOM เปลี่ยนโดยตรงเลยสักบรรทัด — นี่คือความต่างที่ใหญ่ที่สุดจากการเขียน JavaScript แบบเดิม

ลองตอบดู

ลืมใส่ "use client" แล้ว npx next build พัง — error ชี้ไปที่ไฟล์ไหน?

ตัวเลือกที่ 3 ถูก · error จริงขึ้นต้นว่า ./components/t78/CustomerList.tsx:1:19 แล้วชี้ลูกศรไปที่คำว่า useState ในบรรทัด import พอดี
Next.js ยังพิมพ์ Import trace ต่อท้ายให้ด้วย ซึ่งไล่จาก CustomerList.tsx ขึ้นไปหา page.tsx — อ่านสองบรรทัดนั้นแล้วจะรู้ทันทีว่าต้องไปแปะ "use client" ที่บรรทัดบนของ trace (ไฟล์ที่ import hook เข้ามาจริง) ไม่ใช่บรรทัดล่างที่เป็นหน้าเว็บที่เรียกมัน

ในโค้ดของสไลด์ (ไม่มี trim) ผู้ใช้เคาะ space bar หนึ่งครั้ง จะเห็นลูกค้ากี่คน จาก 25?

ตัวเลือกที่ 2 ถูก และเหตุผลสำคัญกว่าตัวเลข · ผลรันจริงคือ search=" " -> 25 คน
ตัวเลือกที่ 3 ได้ตัวเลขถูกแต่เหตุผลผิด — โค้ดเทียบด้วย !== ตรง ๆ " " ไม่เท่ากับ "" จึงเข้าไปกรองจริง ๆ แล้วบังเอิญตรงทุกคน เพราะทุกชื่อในไฟล์เป็น “ชื่อ เว้นวรรค นามสกุล”
ความต่างนี้สำคัญตอนข้อมูลเปลี่ยน: ถ้ามีลูกค้าชื่อเดี่ยวไม่มีนามสกุล คนนั้นจะหายไปทันทีที่ผู้ใช้เผลอเคาะ space — ซึ่งเป็นบั๊กที่หาสาเหตุยากมาก

ตาราง next build ขึ้นว่า ○ /t78 (Static) ทั้งที่ CustomerList ติด "use client" — แปลว่าอะไร?

ตัวเลือกที่ 4 ถูก · พิสูจน์ได้ด้วย curl ซึ่งไม่รัน JavaScript เลย — ยังนับ age: NN ได้ 25 อัน แปลว่าการ์ดอยู่ใน HTML ตั้งแต่แรก
"use client" ไม่ได้แปลว่า “ห้ามเรนเดอร์บน server” แต่แปลว่า “ต้องส่ง JavaScript ตัวนี้ไปให้ browser ด้วย เพราะมันต้องตอบสนองผู้ใช้”
ผลคือได้ทั้งสองอย่าง: เห็นเนื้อหาทันที (จาก HTML ที่ prerender ไว้) แล้วพิมพ์ค้นหาได้เมื่อ JS โหลดเสร็จ (hydration)

โค้ดตามสไลด์ · ผู้ใช้พิมพ์ zz ซึ่งไม่ตรงกับใครเลย — ผู้ใช้จะเห็นอะไรบนหน้าจอ?

ตัวเลือกที่ 3 ถูก · ผลรันจริงคือ search="zz" -> 0 คน แล้วโค้ดของสไลด์ .map() บน array ว่าง ซึ่งได้ผลลัพธ์เป็น “ไม่มีอะไร” — ไม่ error (ตัดตัวเลือกที่ 4 ออก) แต่ก็ไม่มีข้อความอะไรบอกผู้ใช้เลย
นี่ไม่ใช่บั๊ก เพราะโจทย์ไม่ได้บอกว่าต้องแสดงอะไร · มันคือ ช่องว่างในโจทย์ที่ถูกเติมด้วยความเงียบ
เวอร์ชัน V2 ในภารกิจข้อ 3 เติมข้อความ ไม่พบลูกค้าที่ชื่อมีคำว่า "zz" เข้าไป — ต่างกันแค่สามบรรทัด แต่ต่างกันคนละเรื่องในสายตาผู้ใช้

เอาไปต่อยอด

ถ้ายังมีแรง ลองเพิ่มทีละอย่างในเวอร์ชันของคุณเอง แล้วรัน next build ทุกครั้ง:

ถามกลับได้เลย

ถ้าคุณเลือกคำตอบของคำถาม 5 ข้อต่างจากในบทนี้ แล้วโค้ดออกมาหน้าตาไม่เหมือนกันเลย — นั่นคือผลลัพธ์ที่ถูกต้องของบทนี้ ไม่ใช่ความผิด
เอาโค้ดของคุณมาคุยได้ พร้อมบอกว่าคุณเลือกทางไหนในแต่ละข้อและเพราะอะไร
ถ้า next build พังแล้วอ่าน error ไม่ออก ก็เอาข้อความ error ทั้งก้อนมาเลย โดยเฉพาะบรรทัด Import trace ซึ่งมักบอกคำตอบไว้แล้ว

อ้างอิง