Skip to main content

Live Coding — โจทย์ตัวอย่าง + วิธีคิด

เป้าหมายของหน้านี้ไม่ใช่แค่ "เฉลย" แต่คือ กระบวนการคิดและพูด ระหว่างทำโจทย์สด ซึ่งคือสิ่งที่ interviewer ประเมินจริงๆ มากกว่าคำตอบที่ถูก 100% — ทุกโจทย์มีปุ่ม Copy โค้ด + เปิด StackBlitz ให้วางแล้วรันจริงได้เลย ไม่ต้องติดตั้งอะไรใน เครื่อง

กลยุทธ์ทั่วไปตอน Live Coding React​

  1. ถามก่อนว่าต้อง handle edge case ไหน: loading state? error state? race condition (request เก่าตอบช้ากว่า request ใหม่)?
  2. แยก logic ออกจาก UI ตั้งแต่แรก — เขียนเป็น custom hook ก่อน แล้วค่อยต่อ UI เข้าไป แสดงว่าคิดเรื่อง separation of concerns
  3. พูดถึง cleanup เสมอ เวลามี subscription/timer/pending request ใน useEffect
  4. บอก trade-off ของ approach ที่เลือก แม้ interviewer จะไม่ถาม (เช่น "ผมเลือก debounce แทน throttle เพราะ...")
🧠 จำง่ายๆ

ย่อเป็น "ถาม-แยก-เคลียร์-บอก": ถาม edge case → แยก logic เป็น hook → อย่าลืม cleanup → บอก trade-off ปิดท้าย

โจทย์ 1: useDebounce Custom Hook​

คำถาม: เขียน custom hook useDebounce(value, delay) ที่ delay การอัปเดตค่าจนกว่า value จะหยุดเปลี่ยนเป็นเวลา delay ms (ใช้กับ search input ที่ไม่อยากยิง API ทุกตัวอักษรที่พิมพ์)

แนวคิดที่ต้องพูด: cleanup function คือหัวใจของ debounce — ทุกครั้งที่ value เปลี่ยนก่อน delay ครบ effect เก่าจะถูก cleanup (clear timer เก่า) ก่อนที่ effect ใหม่จะเริ่ม นี่คือ กลไกที่ทำให้ "พิมพ์ติดกันเร็วๆ" ไม่ trigger การอัปเดตจนกว่าจะหยุดพิมพ์จริงๆ

ลองรันจริง​

import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);

// cleanup: ถ้า value เปลี่ยนก่อน delay ครบ ให้ยกเลิก timer เก่าทิ้ง
return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}

export default function App() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const [log, setLog] = useState([]);

useEffect(() => {
if (debouncedQuery) {
setLog((prev) => [...prev, `searched: "${debouncedQuery}"`]);
}
}, [debouncedQuery]);

return (
<div style={{ fontFamily: 'sans-serif', padding: 20 }}>
<h2>useDebounce demo</h2>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="พิมพ์เร็วๆ ดูสิ..."
style={{ padding: 8, fontSize: 16, width: 260 }}
/>
<p>live value: <strong>{query}</strong></p>
<p>debounced value (500ms): <strong>{debouncedQuery}</strong></p>
<h4>search log (ยิงเฉพาะตอนหยุดพิมพ์):</h4>
<ul>{log.map((l, i) => <li key={i}>{l}</li>)}</ul>
</div>
);
}
วาง (Ctrl/Cmd+V) แทนเนื้อหาใน App.tsx ของโปรเจกต์ที่เปิดขึ้นมา — รันจริง บน StackBlitz ไม่ต้องติดตั้งอะไรในเครื่อง
🧠 จำง่ายๆ

"Debounce = รอจนกว่าจะเงียบ, Throttle = จำกัดความถี่สูงสุด" — debounce เหมือนลิฟต์ที่รอประตู ปิดจนกว่าจะไม่มีคนเดินเข้ามาอีก (รีเซ็ตนับใหม่ทุกครั้งที่มีคนเข้า) ส่วน throttle เหมือนรถเมล์ที่ ออกทุก 5 นาทีตายตัว ไม่ว่าคนจะมาถี่แค่ไหน

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

Hook นี้มี race condition ที่ซ่อนอยู่ซึ่งคนมักมองข้าม: ถ้า fetchSearchResults ของ query เก่า (เช่น "reac") ตอบกลับช้ากว่า query ใหม่ (เช่น "react") เพราะ network latency ไม่คงที่ ผลลัพธ์ของ "reac" ที่มาทีหลังอาจเขียนทับผลลัพธ์ที่ถูกต้องของ "react" ทำให้ user เห็นผลลัพธ์ ผิด query วิธีแก้ที่ถูกต้องคือเพิ่ม cleanup flag หรือ AbortController ใน effect ที่ทำ fetch (ดูรายละเอียดเต็มในโจทย์ 2 ด้านล่าง) — เป็น follow-up question ที่ interviewer senior เกือบทุกคนถามต่อหลังเห็น debounce hook พื้นฐาน

โจทย์ 2: useFetch Custom Hook​

คำถาม: เขียน custom hook useFetch(url) ที่ return { data, loading, error } และต้อง cleanup ถูกต้องเมื่อ component unmount หรือ url เปลี่ยนก่อน request เดิมเสร็จ

จุดที่ interviewer มักจับผิด: ลืม AbortController ทำให้ request เก่าที่ยังไม่เสร็จ (หลัง component unmount หรือ url เปลี่ยน) พอ resolve กลับมาแล้วพยายาม setState บน component ที่ตายไปแล้ว — เกิด warning "Can't perform a React state update on an unmounted component" (React เวอร์ชันเก่า) หรือ memory leak เบาๆ

ลองรันจริง​

import { useState, useEffect } from 'react';

function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
if (!url) return;
const controller = new AbortController();

setLoading(true);
setError(null);

fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => setData(json))
.catch((err) => {
if (err.name !== 'AbortError') setError(err); // ไม่ setError ถ้าแค่ถูก abort เอง
})
.finally(() => setLoading(false));

return () => controller.abort(); // cleanup: ยกเลิก request ที่ยังค้างอยู่
}, [url]);

return { data, loading, error };
}

export default function App() {
const [userId, setUserId] = useState(1);
const { data, loading, error } = useFetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);

return (
<div style={{ fontFamily: 'sans-serif', padding: 20 }}>
<h2>useFetch demo</h2>
<div style={{ marginBottom: 12 }}>
{[1, 2, 3, 4, 5].map((id) => (
<button
key={id}
onClick={() => setUserId(id)}
style={{ marginRight: 6, fontWeight: id === userId ? 'bold' : 'normal' }}
>
User {id}
</button>
))}
</div>
{loading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>Error: {error.message}</p>}
{data && (
<div>
<p><strong>{data.name}</strong></p>
<p>{data.email}</p>
</div>
)}
<p style={{ color: '#888', fontSize: 13 }}>
ลองคลิกสลับ user เร็วๆ ดู — AbortController จะยกเลิก request เก่าที่ยังค้างให้เอง
</p>
</div>
);
}
วาง (Ctrl/Cmd+V) แทนเนื้อหาใน App.tsx ของโปรเจกต์ที่เปิดขึ้นมา — รันจริง บน StackBlitz ไม่ต้องติดตั้งอะไรในเครื่อง
🧠 จำง่ายๆ

"AbortController = ใบยกเลิกออเดอร์ที่ส่งไปครัวแล้ว" — สั่งอาหารแล้วเปลี่ยนใจสั่งใหม่ ต้องส่ง ใบยกเลิกไปที่ครัว (abort) ไม่ใช่แค่ "ไม่สนใจ" ออเดอร์เก่าเฉยๆ เพราะครัวจะทำอาหารเก่าต่อไปแล้ว เสิร์ฟมาทับโต๊ะผิดในที่สุด

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

ตั้งแต่ React 18 + Strict Mode (dev mode เท่านั้น) component จะ mount → unmount → mount ใหม่ทันที โดยตั้งใจ (เพื่อช่วยจับบั๊กเรื่อง cleanup ที่ไม่สมบูรณ์) ทำให้ useFetch ที่เขียน cleanup ไม่ถูกต้องจะยิง fetch ซ้ำสองครั้งเห็นชัดใน dev mode (แต่ไม่เกิดใน production build) หลายคนตกใจคิดว่าเป็นบั๊กของ React ทั้งที่จริงๆ มันคือ "feature ที่ตั้งใจให้งงแล้วไปแก้" — ถ้า AbortController cleanup ถูกต้อง การยิงซ้ำนี้จะไม่มีผลเสียอะไรเลย (request แรกถูก abort ทันที request ที่สองทำงานตามปกติ) เป็นวิธีที่ดีในการ "เทส" ว่า custom hook ของเราเขียน cleanup ถูกต้องจริงหรือเปล่าโดยไม่ต้องรอเจอบั๊กใน production

โจทย์ 3: Autocomplete / Search Component​

คำถาม: ประกอบ useDebounce + useFetch (จากโจทย์ 1-2) เป็น Autocomplete component ที่ ใช้งานได้จริง รองรับ keyboard navigation (ลูกศรขึ้น/ลง, Enter เลือก)

พูดเรื่อง accessibility ถ้ามีเวลาเหลือ: ควรเพิ่ม ARIA attribute (role="combobox", aria-activedescendant) ให้ screen reader ใช้งานได้ — การพูดถึงเรื่องนี้เองโดยไม่ต้องถูกถาม มักสร้างความประทับใจกับ interviewer ที่ใส่ใจ accessibility

ลองรันจริง​

import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

const FRUITS = ['Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry', 'Coconut', 'Date', 'Durian', 'Fig', 'Grape'];

function useFakeSearch(query) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (!query) { setResults([]); return; }
let cancelled = false;
setLoading(true);
const timer = setTimeout(() => {
if (cancelled) return;
const matches = FRUITS.filter((f) => f.toLowerCase().includes(query.toLowerCase()));
setResults(matches);
setLoading(false);
}, 300); // จำลอง network latency
return () => { cancelled = true; clearTimeout(timer); };
}, [query]);

return { results, loading };
}

export default function App() {
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const [selected, setSelected] = useState(null);
const debouncedQuery = useDebounce(query, 300);
const { results, loading } = useFakeSearch(debouncedQuery);

function handleKeyDown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && activeIndex >= 0 && results[activeIndex]) {
setSelected(results[activeIndex]);
}
}

return (
<div style={{ fontFamily: 'sans-serif', padding: 20 }}>
<h2>Autocomplete demo</h2>
<input
value={query}
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); setSelected(null); }}
onKeyDown={handleKeyDown}
placeholder="พิมพ์ชื่อผลไม้ เช่น a, b, c..."
style={{ padding: 8, fontSize: 16, width: 260 }}
/>
{loading && <p>Searching...</p>}
<ul style={{ listStyle: 'none', padding: 0, maxWidth: 260 }}>
{results.map((item, i) => (
<li
key={item}
onClick={() => setSelected(item)}
style={{
padding: '6px 10px',
background: i === activeIndex ? '#def' : 'transparent',
cursor: 'pointer',
}}
>
{item}
</li>
))}
</ul>
{selected && <p>เลือกแล้ว: <strong>{selected}</strong></p>}
</div>
);
}
วาง (Ctrl/Cmd+V) แทนเนื้อหาใน App.tsx ของโปรเจกต์ที่เปิดขึ้นมา — รันจริง บน StackBlitz ไม่ต้องติดตั้งอะไรในเครื่อง
🧠 จำง่ายๆ

"ประกอบ hook เหมือนประกอบ Lego" — โจทย์นี้ไม่มีอะไรใหม่เลย แค่เอา useDebounce + data-fetching logic ที่เขียนไว้แล้วมา "เสียบ" เข้าด้วยกัน เป็นการโชว์ว่าการแยก logic เป็น custom hook ตั้งแต่แรกทำให้โจทย์ที่ดู "ซับซ้อน" กลายเป็นแค่การประกอบชิ้นส่วนที่มีอยู่แล้ว

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

activeIndex ที่ reset เป็น -1 ทุกครั้งที่ query เปลี่ยน (ใน onChange) แต่ ไม่ reset เมื่อ results เปลี่ยนจาก debounce/fetch ที่เสร็จทีหลัง คือกับดักที่ซ่อนอยู่: ถ้า user กดลูกศรลงไปเลือก item index 3 ของผลลัพธ์ชุดเก่า แล้วพอดี debounced search ของ query ใหม่เพิ่ง resolve กลับมาพร้อมผลลัพธ์ชุดใหม่ที่มีแค่ 2 item, activeIndex=3 จะชี้ไปยัง item ที่ไม่มีอยู่ จริง (results[3] เป็น undefined) ถ้า user กด Enter ตอนนั้นพอดีจะเลือกค่า undefined ไป เงียบๆ — วิธีแก้คือ clamp activeIndex ทุกครั้งที่ results เปลี่ยนด้วย useEffect แยก หรือ คำนวณ effectiveIndex = Math.min(activeIndex, results.length - 1) ตอน render แทนที่จะพึ่ง state ดิบๆ ตรงๆ — เป็นตัวอย่างคลาสสิกของบั๊กที่เกิดจาก state สองก้อนที่ไม่ sync กัน (query-driven state กับ fetch-driven state) ซึ่งเป็นธีมที่ย้ำซ้ำๆ ตลอดทั้งหมวดนี้

English Summary

Three recurring React live-coding patterns: (1) a useDebounce hook, where the cleanup function clearing the previous timer is the core mechanism, and handling out-of-order async responses (a slower old request overwriting a faster new one) is the near-universal senior-level follow-up; (2) a useFetch hook using AbortController for proper cleanup — React 18 Strict Mode's dev- only mount-unmount-remount cycle is a built-in test for whether this cleanup actually works, not a bug; (3) composing both into an Autocomplete component with keyboard navigation, where a classic hidden bug is an activeIndex state that goes stale relative to a separately-updated results list — two states that can drift out of sync is a recurring theme across all three problems. Across all: ask about edge cases up front, extract logic into custom hooks before wiring up UI, always address cleanup, and state trade-offs unprompted.