Lynx.js Workshop ตอนที่ 3: Advanced Integration และ Best Practices
Lynx.js Workshop ตอนที่ 3: Advanced Integration และ Best Practices
อ้างอิงโค้ดจริงจาก: โปรเจกต์
frontend-mobileใน kangana1024/showkhun-workshop (branchstep-14-mobile-alerts)
มาถึงตอนสุดท้ายของซีรีส์แล้วครับ! 🎉
สองตอนที่แล้วเราอ่านข้อมูลอย่างเดียว — ดู dashboard, ดูค่าสด ตอนนี้ถึงเวลา “สั่งงานกลับ” และทำของที่ production app จริงต้องมี: สั่งเปิด/ปิด device พร้อม dialog ยืนยันกันกดพลาด, วาดกราฟจากข้อมูลย้อนหลัง, จัดการ alert rules, และทำ dark mode แบบมืออาชีพ
ที่สำคัญ — ทุกอย่างในตอนนี้มีเรื่อง security กับ edge case แฝงอยู่ ซึ่งเป็นสิ่งที่ tutorial ทั่วไปมักข้าม แต่พี่โชว์จะชี้ให้เห็นว่าโค้ดจริงเขาคิดเผื่ออะไรไว้บ้าง มาลุยกัน!
สิ่งที่จะได้เรียนรู้
- สั่งงาน device ด้วย command preset ตาม device type + dialog ยืนยัน สำหรับ action อันตราย
- วาด line chart จาก time-series ด้วย SVG ที่ build แบบ กัน injection by construction
- จัดการ alert rules จาก backend + read/unread state ฝั่ง client (เพราะ backend ไม่มี)
- notification preferences ผ่าน React Context
- theme dark/light ด้วย Context + CSS variables
- Best practices: abort, in-app affordance, การไม่ฝัง secret, การรู้ขอบเขตของ backend contract
Part A: สั่งงาน Device (Command + Confirmation)
WHY: ทำไม command ต้องมี dialog ยืนยัน?
ลองนึกภาพปุ่ม “Reboot device” บนหน้าจอ ถ้ากดปุ๊บทำงานปั๊บ แล้วนิ้วเราพลาดไปโดน — device รีบูตกลางดึกซะงั้น 😱
action บางอย่าง “ย้อนกลับไม่ได้” หรือ “มีผลกระทบ” เราเลยต้องมีด่านยืนยัน เหมือนตอนจะลบไฟล์สำคัญ ระบบถามซ้ำ “แน่ใจนะ?” — กันพลาดไว้ก่อน
ในโค้ดเรา command ถูกนิยามเป็น preset แยกตาม device type ในไฟล์ commands.ts พร้อม flag destructive บอกว่าอันไหนอันตราย:
export interface CommandPreset {
label: string
/** action ต้องผ่าน validator ฝั่ง backend: ตัวอักษร/ตัวเลข + ขีดกลาง */
command: CommandPayload
destructive?: boolean
}
const BY_TYPE: Record<DeviceType, CommandPreset[]> = {
relay: [
{ label: 'Turn on', command: { action: 'turn-on' } },
{ label: 'Turn off', command: { action: 'turn-off' }, destructive: true },
],
motion: [
{ label: 'Arm', command: { action: 'arm' } },
{ label: 'Disarm', command: { action: 'disarm' }, destructive: true },
],
temperature_humidity: [
{ label: 'Calibrate', command: { action: 'calibrate' } },
],
gateway: [
{ label: 'Sync devices', command: { action: 'sync' } },
],
}
/** คืน command ที่ใช้ได้ของ device type นั้น + command ร่วม (reboot ฯลฯ) */
export function commandsForType(type: DeviceType): CommandPreset[] {
return [...(BY_TYPE[type] ?? []), ...COMMON]
}
สังเกต comment บรรทัด
action— มันต้องผ่าน validator ฝั่ง backend (ตัวอักษร/ตัวเลข + ขีดกลาง) นี่คือการ “คิดเผื่อ” ว่า client กับ backend ต้องตกลง contract กันให้ตรง
Flow ของการสั่งงาน
graph LR
A[แตะปุ่ม command] --> B{destructive?}
B -->|ใช่| C[🛑 เปิด Dialog ยืนยัน]
B -->|ไม่| C
C -->|กด Confirm| D[POST /api/v1/devices/:id/commands]
D --> E[แสดง feedback]
C -->|กด Cancel| F[ปิด ไม่ทำอะไร]
ใน DeviceDetail.tsx เราเก็บ command ที่กำลังจะยืนยันไว้ใน state pending แล้วเปิด Dialog:
const [pending, setPending] = useState<CommandPreset | null>(null)
// ปุ่มแต่ละอันแค่ set pending ยังไม่ยิงคำสั่งทันที
{commandsForType(device.type).map((preset) => (
<Button
key={preset.command.action}
label={preset.label}
variant={preset.destructive ? 'danger' : 'secondary'}
disabled={sending}
onTap={() => setPending(preset)}
/>
))}
// Dialog จะโผล่เมื่อมี pending — กด Confirm ถึงจะยิงจริง
<Dialog
visible={pending !== null}
title={pending ? `${pending.label}?` : ''}
message={pending && device ? `Send "${pending.command.action}" to ${device.name}?` : undefined}
destructive={pending?.destructive}
busy={sending}
onConfirm={confirmCommand}
onCancel={() => setPending(null)}
/>
ตัว Dialog เองมีลูกเล่นเล็ก ๆ ที่ดี — ถ้าไม่ visible มัน return null ไม่กิน layout เลย และตอน busy ปุ่มจะ disable กัน double-submit:
export function Dialog(props: DialogProps) {
if (!props.visible) return null // ซ่อนแล้วหายไปจาก layout เลย
return (
<view className='Dialog__backdrop'>
<view className='Dialog__card'>
<text className='Dialog__title'>{props.title}</text>
<view className='Dialog__actions'>
<Button label='Cancel' variant='secondary' onTap={props.onCancel} disabled={props.busy} />
<Button
label={props.busy ? 'Working…' : props.confirmLabel ?? 'Confirm'}
variant={props.destructive ? 'danger' : 'primary'}
onTap={props.onConfirm}
disabled={props.busy}
/>
</view>
</view>
</view>
)
}
Part B: วาด Chart จาก Time-series
WHY: ทำไมต้อง build SVG เองด้วยมือ?
แทนที่จะลากไลบรารี chart หนัก ๆ เข้ามา โปรเจกต์เราเลือก สร้าง SVG string เอง แล้ว render ผ่าน element <svg> ของ Lynx เหตุผลคือเบา ควบคุมได้เต็มที่ และ — ที่สำคัญ — ปลอดภัยจาก injection เพราะข้อมูลค่ามาจาก backend
แต่เดี๋ยวก่อน! ข้อมูลจาก server เอามายัดใส่ SVG string ตรง ๆ มันเสี่ยง markup injection นะ โค้ดเลยคิดเผื่อสองชั้น
ชั้นแรก — ดึงข้อมูลผ่าน hook useReadings ที่แปลง row ดิบเป็นจุด {t, v} โดย กรองเฉพาะตัวเลขที่ valid เท่านั้น:
/** ดึงเฉพาะค่าที่เป็นตัวเลขออกมา เรียงตามเวลา */
function toPoints(rows: ReadingRow[]): TimePoint[] {
const points: TimePoint[] = []
for (const row of rows) {
const value = typeof row.value === 'number' ? row.value : Number(row.value)
const time = Date.parse(row.time)
if (Number.isFinite(value) && !Number.isNaN(time)) { // ค่าเพี้ยน/NaN ถูกทิ้ง
points.push({ t: time, v: value })
}
}
points.sort((a, b) => a.t - b.t)
return points
}
ชั้นสอง — ตอน build SVG ใน chart.ts มันinterpolate แต่ตัวเลขลงไปใน path เท่านั้น (ค่า server เป็น number หมดแล้ว) ส่วนสีก็ผ่าน safeColor() ที่ validate รูปแบบก่อน:
/**
* validate CSS color token — ยอมแค่รูปแบบที่ปลอดภัย
* เพื่อให้ค่าสี "หลุดออกนอก" attribute ของ SVG ไม่ได้
*/
function safeColor(color: string): string {
return /^(#[0-9a-fA-F]{3,8}|rgba?\([\d.,\s]+\)|[a-zA-Z]+)$/.test(color)
? color
: '#888888'
}
/**
* buildLineChartSvg วาดจุดตัวเลขเป็น SVG line chart
* เฉพาะ "ตัวเลข" เท่านั้นที่ถูกใส่ลง path/coordinate
* ค่าจาก server จึง inject markup ไม่ได้
*/
export function buildLineChartSvg(points, dim, colors): string {
// ... คำนวณ scale แล้วต่อ path "M x y L x y ..."
const path = `<path d="${d.trim()}" fill="none" stroke="${safeColor(colors.line)}" stroke-width="2" />`
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${area}${path}${baseline}</svg>`
}
คอมเมนต์ใน source บอกชัดเลยว่า “secure by construction” — สีมาจาก theme ของแอปเอง ไม่ใช่จาก server อยู่แล้ว แต่ก็ยัง validate ไว้ให้ปลอดภัยโดยโครงสร้าง นี่แหละ mindset ของ production code
ฝั่ง component LineChart ก็แค่ส่ง SVG string ที่ build แล้วเข้า prop content ของ <svg> พร้อมเลือกสีตาม theme:
export function LineChart(props: LineChartProps) {
const { theme } = useTheme()
const colors = theme === 'dark'
? { line: '#5b8dff', fill: 'rgba(91,141,255,0.18)', axis: '#2a3548' }
: { line: '#2f6df6', fill: 'rgba(47,109,246,0.12)', axis: '#dfe3ec' }
const svg = buildLineChartSvg(props.points, { width: 320, height: 160 }, colors)
return <svg className='LineChart' content={svg} />
}
ส่วนหน้า DeviceCharts ให้ผู้ใช้เลือก metric, ช่วงเวลา (1H/6H/24H/7D) และ window สำหรับ downsample — แล้วยิงผ่าน getReadings:
const RANGES = [
{ value: '1h', label: '1H', window: '1m' },
{ value: '6h', label: '6H', window: '5m' },
{ value: '24h', label: '24H', window: '15m' },
{ value: '168h', label: '7D', window: '1h' },
]
const params: ReadingsParams = {
range: range.value,
field,
window: range.window,
aggregate: 'mean',
limit: 500,
}
เกร็ด: backend เป็นคน clamp range/window/limit ให้อยู่ในขอบเขตที่ปลอดภัยอยู่แล้ว (กัน query หนัก ๆ) ฝั่ง client เลยแค่ส่ง “เจตนา” ไปก็พอ — นี่คือการแบ่งความรับผิดชอบที่ดี
Part C: Alert Rules + รู้ขอบเขตของ Backend
WHY: บางอย่าง backend ไม่มีให้ ก็ต้องทำฝั่ง client
นี่เป็นบทเรียนสำคัญของ production จริง — ไม่ใช่ทุกอย่างที่ UI อยากได้ จะมี API รองรับ
หน้า Alerts ของเราโหลด alert rules (ตัว config การเฝ้าระวัง) มาจาก GET /api/v1/alert-rules แต่ backend ไม่มี endpoint ประวัติ alert ที่เคยยิง และ ไม่ได้ broadcast alert ทาง WebSocket (การส่ง alert จริงเป็น webhook ที่ operator ตั้งค่า) คอมเมนต์ใน useAlertRules.ts พูดเรื่องนี้ตรง ๆ:
/**
* useAlertRules โหลด alert rule ที่ตั้งไว้ (ตัว config การ monitor)
* backend เปิดให้ดู alert rule แต่ไม่มี endpoint ประวัติ alert ที่ยิงแล้ว
* อันนี้จึงเป็น source of truth เดียวของหน้า alert
* ส่วน filter ตาม severity ทำฝั่ง client เพราะ endpoint กรองตาม
* type/enabled/device ได้ แต่ไม่กรองตาม severity
*/
export function useAlertRules(): AlertRulesState {
const [allRules, setAllRules] = useState<AlertRule[]>([])
const [severity, setSeverity] = useState<SeverityFilter>('all')
// โหลด rule ทั้งหมด แล้วกรอง severity ในเครื่อง
const rules = severity === 'all'
? allRules
: allRules.filter((r) => r.severity === severity)
return { /* loading, error, */ rules, severity, setSeverity, /* refresh */ }
}
เพราะ backend ไม่มีคอนเซ็ปต์ “อ่านแล้ว/ยังไม่อ่าน” บน alert rule เราเลยทำ read state ฝั่ง client แบบ in-memory ให้เป็น affordance ของ session นี้:
/**
* useReadState เก็บว่า rule ไหนถูก mark ว่าอ่านแล้ว
* backend ไม่มี read/unread บน alert rule นี่จึงเป็น affordance ฝั่ง client
*/
export function useReadState() {
const [read, setRead] = useState<Record<string, boolean>>({})
const isRead = useCallback((id: string) => Boolean(read[id]), [read])
const markRead = useCallback((id: string) => {
setRead((prev) => (prev[id] ? prev : { ...prev, [id]: true }))
}, [])
const markAllRead = useCallback((ids: string[]) => {
setRead((prev) => {
const next = { ...prev }
for (const id of ids) next[id] = true
return next
})
}, [])
return { isRead, markRead, /* toggleRead, */ markAllRead }
}
บทเรียน: การเขียน frontend ที่ดีต้อง เข้าใจ contract ของ backend ตามจริง แล้วออกแบบ UX ให้ซื่อสัตย์กับมัน อย่าแสดง UI หลอก ๆ ว่าทำได้ ทั้งที่ไม่มี API รองรับ — comment ในโค้ดของเราระบุข้อจำกัดพวกนี้ไว้ชัดเจน เป็นมรดกให้คนมาทำต่อ
Part D: Notification Preferences ผ่าน Context
notification preferences ก็เป็น local setting (เพราะ backend ส่ง alert ผ่าน webhook ที่ operator ตั้ง ไม่มี per-user routing) เราเก็บผ่าน React Context เพื่อแชร์ทั้งแอป:
export interface NotificationPrefs {
enabled: boolean
bySeverity: Record<AlertSeverity, boolean>
}
// ค่าเริ่มต้น — เปิดเฉพาะ warning/critical, ปิด info
export const DEFAULT_PREFS: NotificationPrefs = {
enabled: true,
bySeverity: { info: false, warning: true, critical: true },
}
export const NotificationPrefsContext = createContext<NotificationPrefsContextValue>({
prefs: DEFAULT_PREFS,
setEnabled: () => {},
setSeverity: () => {},
})
แล้วใน App.tsx ก็ห่อ provider ครอบทั้งแอป ทำให้ทุกหน้าเข้าถึง prefs เดียวกันได้ — pattern เดียวกับ React บนเว็บเป๊ะ
Part E: Theme Dark/Light ด้วย Context + CSS Variables
ปิดท้ายด้วยของที่ผู้ใช้รักที่สุด — dark mode 🌙 วิธีของ Lynx เนียนมาก: เก็บ theme ปัจจุบันใน Context แล้วสะท้อนลง attribute data-theme บน <view> ที่ครอบ ทำให้ palette CSS variable ใน theme.css มีผลทั้ง subtree:
export function ThemeProvider(props: ThemeProviderProps) {
const [theme, setTheme] = useState<ThemeName>(props.initial ?? 'light')
const toggle = useCallback(() => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
}, [])
const value = useMemo(() => ({ theme, toggle, setTheme }), [theme, toggle])
return (
<ThemeContext.Provider value={value}>
{/* data-theme คุม CSS variable ของทั้ง subtree */}
<view data-theme={theme} className='ThemeRoot'>
{props.children}
</view>
</ThemeContext.Provider>
)
}
หน้าไหนอยากรู้ธีมหรือสลับธีม ก็แค่เรียก hook useTheme():
export function SettingsScreen() {
const { theme, toggle } = useTheme()
return (
// ...
<Button
label={theme === 'dark' ? 'Switch to light' : 'Switch to dark'}
variant='secondary'
onTap={toggle}
/>
)
}
อุปมา:
data-themeเหมือนสวิตช์ไฟหลักของบ้าน — กดทีเดียว ไฟทุกห้อง (component ทุกตัว) เปลี่ยนโทนตามทันที เพราะทุกห้องดึงสีจาก CSS variable ชุดเดียวกัน
สรุป Best Practices จากโค้ดจริง
ตลอดทั้งซีรีส์ เราเจอ pattern ของ production app ที่ดีหลายอย่าง มารวบให้ตรงนี้:
| Best Practice | เห็นได้จากตรงไหน |
|---|---|
| ไม่ฝัง host/secret ลง bundle | lynx.config.ts inject SHOWKHUN_API_BASE_URL ตอน build |
| Typed contract กับ backend | api/types.ts field ตรงกับ Go struct เป๊ะ |
| Timeout + abort ทุก request | request() ใน client.ts |
| Auto-reconnect + re-subscribe | RealtimeClient exponential backoff |
| ยืนยันก่อน action อันตราย | destructive preset + Dialog |
| SVG ปลอดภัยจาก injection | safeColor() + interpolate แต่ตัวเลข |
| ซื่อสัตย์กับ backend contract | read state / prefs เป็น affordance ฝั่ง client พร้อม comment อธิบาย |
| Theme ผ่าน Context + CSS var | ThemeProvider + data-theme |
สรุปทั้งซีรีส์
ใน 3 ตอนนี้ น้อง ๆ ได้สร้าง mobile client ของระบบ IoT จริงด้วย Lynx.js:
- ตอนที่ 1 — รู้จัก ReactLynx, setup โปรเจกต์, รันบนมือถือด้วย QR, และ element พื้นฐาน
- ตอนที่ 2 — typed REST client + WebSocket auto-reconnect, ประกอบเป็น Dashboard real-time
- ตอนที่ 3 — สั่งงาน device, วาด chart, จัดการ alert, theme และ best practices
ที่สำคัญที่สุด — ทุกบรรทัดในซีรีส์นี้ดึงมาจากโค้ดที่ run ได้จริงใน frontend-mobile ไม่ใช่ตัวอย่างลอย ๆ ดังนั้นน้องเอาไปต่อยอดเป็นโปรเจกต์ของตัวเองได้เลย 🚀
╔══════════════════════════════╗
║ ║
║ \(^o^)/ ║
║ ║
║ "จบซีรีส์! ไปสร้างของจริงกัน" ║
║ ║
╚══════════════════════════════╝
Navigation
- ก่อนหน้า: Lynx.js Workshop ตอนที่ 2: ดึงข้อมูล IoT แบบ Real-time
- กลับไปตอนแรก: Lynx.js Workshop ตอนที่ 1: เริ่มต้นเขียน Mobile App ด้วย ReactLynx
Happy IoT Building! 🌟