Live Coding — โจทย์ตัวอย่าง + วิธีคิด
เป้าหมายของหน้านี้ไม่ใช่แค่ "เฉลย" แต่คือ กระบวนการคิดและพูด ระหว่างทำโจทย์สด ซึ่งคือสิ่งที่ interviewer ประเมินจริงๆ มากกว่าคำตอบที่ถูก 100% — ทุกโจทย์มีปุ่ม Copy โค้ด + เปิด StackBlitz ให้วางแล้วรันจริงได้เลย ไม่ต้องติดตั้งอะไรใน เครื่อง
กลยุทธ์ทั่วไปตอน Live Coding React
- ถามก่อนว่าต้อง handle edge case ไหน: loading state? error state? race condition (request เก่าตอบช้ากว่า request ใหม่)?
- แยก logic ออกจาก UI ตั้งแต่แรก — เขียนเป็น custom hook ก่อน แล้วค่อยต่อ UI เข้าไป แสดงว่าคิดเรื่อง separation of concerns
- พูดถึง cleanup เสมอ เวลามี subscription/timer/pending request ใน
useEffect - บอก 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>
);
}
App.tsx ของโปรเจกต์ที่เปิดขึ้นมา — รันจริง บน StackBlitz ไม่ต้องติดตั้งอะไรในเครื่อง"Debounce = รอจนกว่าจะเงียบ, Throttle = จำกัดความถี่สูงสุด" — debounce เหมือนลิฟต์ที่รอประตู ปิดจนกว่าจะไม่มีคนเดินเข้ามาอีก (รีเซ็ตนับใหม่ทุกครั้งที่มีคนเข้า) ส่วน throttle เหมือนรถเมล์ที่ ออกทุก 5 นาทีตายตัว ไม่ว่าคนจะมาถี่แค่ไหน
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>
);
}
App.tsx ของโปรเจกต์ที่เปิดขึ้นมา — รันจริง บน StackBlitz ไม่ต้องติดตั้งอะไรในเครื่อง"AbortController = ใบยกเลิกออเดอร์ที่ส่งไปครัวแล้ว" — สั่งอาหารแล้วเปลี่ยนใจสั่งใหม่ ต้องส่ง ใบยกเลิกไปที่ครัว (abort) ไม่ใช่แค่ "ไม่สนใจ" ออเดอร์เก่าเฉยๆ เพราะครัวจะทำอาหารเก่าต่อไปแล้ว เสิร์ฟมาทับโต๊ะผิดในที่สุด
ตั้งแต่ 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>
);
}
App.tsx ของโปรเจกต์ที่เปิดขึ้นมา — รันจริง บน StackBlitz ไม่ต้องติดตั้งอะไรในเครื่อง"ประกอบ hook เหมือนประกอบ Lego" — โจทย์นี้ไม่มีอะไรใหม่เลย แค่เอา useDebounce +
data-fetching logic ที่เขียนไว้แล้วมา "เสียบ" เข้าด้วยกัน เป็นการโชว์ว่าการแยก logic เป็น
custom hook ตั้งแต่แรกทำให้โจทย์ที่ดู "ซับซ้อน" กลายเป็นแค่การประกอบชิ้นส่วนที่มีอยู่แล้ว
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) ซึ่งเป็นธีมที่ย้ำซ้ำๆ ตลอดทั้งหมวดนี้
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.