# เรียลไทม์ในรูปแบบ hooks: แพ็กเกจของเราสำหรับ React, Vue และ Nuxt

> ทุกทีมเขียน useEffect แบบเดียวกันเพื่อต่อ pusher-js เข้ากับ React หรือ Vue และเจอบั๊กแบบเดียวกัน เราจึงรวมเป็นสามแพ็กเกจ และให้มันสตรีมคำตอบของ AI ไปถึงทุกแท็บและทุกคนในทีม

Source: https://hibira.io/th/blog/react-vue-nuxt-realtime
Updated: 2026-11-03

การต่อ pusher-js เข้ากับ React มักเริ่มต้นแบบนี้:

```jsx
useEffect(() => {
  const channel = pusher.subscribe('orders');
  channel.bind('order.created', (order) => setOrders((list) => [order, ...list]));
  return () => pusher.unsubscribe('orders');
}, []);
```

หกบรรทัด ดูไม่มีปัญหาอะไร แต่พอโปรเจกต์โตขึ้น รายการคำสั่งซื้อและกระดิ่งแจ้งเตือนมุมขวาบนต่างก็ฟัง `orders` เหมือนกัน เมื่อเปลี่ยนหน้า รายการถูก unmount ไป กระดิ่งยังอยู่ แต่หลังจากนั้นกระดิ่งก็ไม่ได้รับแจ้งเตือนอีกเลย ในโหมดพัฒนา StrictMode ของ React จะ mount คอมโพเนนต์สองครั้ง การเชื่อมต่อก็เปิดสองเส้นตามไปด้วย พอย้ายไป Next.js ตัว `new Pusher()` ก็รันตอนเรนเดอร์ฝั่งเซิร์ฟเวอร์ด้วย เปิด WebSocket บนเซิร์ฟเวอร์ทิ้งไว้โดยไม่มีใครใช้

เราเห็นโค้ดแบบนี้ในหลายโปรเจกต์ เขียนแทบจะเหมือนกันทุกครั้ง และเจอกับดักเดียวกันทุกครั้ง เราจึงรวบรวมมันเป็นสามแพ็กเกจ: **@hibira-io/react**, **@hibira-io/vue** และ **@hibira-io/nuxt** บทความนี้เล่าว่าแพ็กเกจเหล่านี้แก้ปัญหาอะไร แก้อย่างไร และอีกสิ่งหนึ่งที่เราทำเพิ่มไประหว่างทาง คือการส่งคำตอบของ AI ไปถึงทุกแท็บและเพื่อนร่วมทีมทุกคนขณะที่มันกำลังถูกเขียน

## กับดักที่หนึ่ง: สองคอมโพเนนต์ หนึ่งแชนเนล

`subscribe()` ของ pusher-js จะ subscribe ชื่อแชนเนลเดียวกันเพียงครั้งเดียว สองคอมโพเนนต์จึงได้ออบเจกต์แชนเนลตัวเดียวกัน ปัญหาอยู่ที่ `unsubscribe()` มันไม่สนว่ายังมีใครใช้อยู่ แต่ยกเลิกทั้งแชนเนลทันที

```text
sequence: สองคอมโพเนนต์ใช้แชนเนล orders ร่วมกัน แล้วตัวหนึ่งถูก unmount
รายการคำสั่งซื้อ | กระดิ่งแจ้งเตือน | pusher-js
1 > 3: subscribe('orders')
2 > 3: subscribe('orders') | ได้แชนเนลเดียวกัน
1 > 3: unsubscribe('orders') | รายการถูก unmount
note: ทั้งแชนเนลถูกยกเลิก กระดิ่งไม่ได้รับแจ้งเตือนอีก
```

วิธีแก้ของเราคือนับจำนวนต่อแชนเนล ทุกคอมโพเนนต์ที่ใช้มันบวกหนึ่ง ทุกครั้งที่ unmount ลบหนึ่ง และจะยกเลิกจริงก็ต่อเมื่อนับถึงศูนย์ คุณแค่เขียน `useChannel('orders')` หรือ `useEvent('orders', 'order.created', handler)` แล้วไม่ต้องสนใจว่าใคร mount ก่อนหรือใครจากไปก่อน

```text
flow: ใช้ตัวนับตัดสินว่าจะยกเลิกจริงเมื่อไร
== Mount
รายการคำสั่งซื้อ|useEvent('orders') --นับ 1--> *แชนเนล orders|subscribe แล้ว --นับ 2--> กระดิ่งแจ้งเตือน|useEvent('orders')
== Unmount
รายการคำสั่งซื้อ|unmount --นับ 1--> *แชนเนล orders|ยังคง subscribe --นับ 0--> กระดิ่งแจ้งเตือน|ยกเลิกหลังจากมันออกไป
```

`useEvent` ยังจำ handler ล่าสุดของคุณไว้ด้วย คอมโพเนนต์สร้างฟังก์ชันใหม่ทุกครั้งที่เรนเดอร์ ถ้า bind อีเวนต์ใหม่ทุกครั้ง แชนเนลที่มีข้อความเข้าถี่ ๆ จะมีช่องว่างสั้น ๆ ที่พลาดอีเวนต์ได้ เรา bind ครั้งเดียว แล้วเรียก handler ตัวล่าสุดเมื่ออีเวนต์มาถึง

## กับดักที่สอง: การเรนเดอร์ฝั่งเซิร์ฟเวอร์และ StrictMode

Next.js, Remix และ Nuxt เรนเดอร์หน้าบนเซิร์ฟเวอร์ก่อน ถ้าคุณสร้างการเชื่อมต่อในตัวคอมโพเนนต์หรือใน `useMemo` เซิร์ฟเวอร์ก็จะเชื่อมต่อด้วย ได้ WebSocket เพิ่มมาหนึ่งเส้นที่ไม่มีใครใช้ และถูกนับจำนวนการเชื่อมต่อเพิ่มอีกหนึ่ง

ตั้งแต่ React 18 StrictMode จะ mount, unmount และ mount คอมโพเนนต์อีกครั้งในโหมดพัฒนา เพื่อหา side effect ที่ไม่ได้เก็บกวาด โค้ดเชื่อมต่อที่เขียนเองมักทิ้งการเชื่อมต่อเส้นที่สองไว้ตรงนี้

```text
flow: เชื่อมต่อเฉพาะในเบราว์เซอร์ และหลัง mount เท่านั้น
เรนเดอร์ฝั่งเซิร์ฟเวอร์|ไม่เชื่อมต่อ hooks คืนค่าว่าง --HTML--> Hydration ในเบราว์เซอร์|หน้าตาตรงกับเซิร์ฟเวอร์ --หลัง mount--> *เชื่อมต่อ|ws.hibira.io
```

`HibiraProvider` เชื่อมต่อเฉพาะใน `useEffect` และตัดการเชื่อมต่อเมื่อ unmount การ mount ครั้งที่สองของ StrictMode จึงได้การเชื่อมต่อใหม่ที่สะอาด ก่อนการเชื่อมต่อจะเปิด `usePresence` คืนรายชื่อสมาชิกว่าง และ `useConnectionState` คืน `initialized` ซึ่งตรงกับที่เซิร์ฟเวอร์เรนเดอร์ไว้ hydration จึงไม่เตือนเรื่องไม่ตรงกัน เวอร์ชัน Vue ก็ทำแบบเดียวกัน ปลั๊กอินจะเชื่อมต่อเมื่อถูกใช้ครั้งแรกในเบราว์เซอร์ และไม่ทำอะไรเลยบนเซิร์ฟเวอร์

## อีกเรื่องหนึ่ง: คำตอบของ AI ไม่ใช่แค่สำหรับแท็บที่ถาม

สองปีที่ผ่านมา ผลิตภัณฑ์จำนวนมากเพิ่มผู้ช่วย AI เข้าไป วิธีที่นิยมคือ Server-Sent Events เบราว์เซอร์ส่งคำขอ แล้วแบ็กเอนด์เขียนผลลัพธ์ของโมเดลกลับมาในการตอบกลับเดียวกันทีละช่วง

แบบนี้ใช้ได้ดีเมื่อมีคนเดียวกับแท็บเดียว แต่เมื่อผลิตภัณฑ์กลายเป็นงานที่ทำร่วมกัน ปัญหาก็ตามมา Alice ถาม AI ในเอกสารสเปกที่แชร์กัน Bob ที่นั่งข้าง ๆ มองไม่เห็นคำตอบที่กำลังถูกเขียน ต้องรอให้ฝั่ง Alice เขียนเสร็จ บันทึก แล้ว Bob รีเฟรชถึงจะเห็น แท็บอีกอันที่ Alice เปิดไว้เองก็มองไม่เห็นเช่นกัน มือถือหลุดสัญญาณไปแป๊บเดียว การตอบกลับของ SSE ก็ขาด เหลือคำตอบแค่ครึ่งเดียว

```text
flow: SSE ตอบเฉพาะคำขอที่ถาม แชนเนลส่งถึงทุกคนที่ subscribe
== Server-Sent Events
โมเดล --สตรีม--> แบ็กเอนด์ของคุณ --HTTP response เดียวกัน--> แท็บที่ถาม
== ส่งเข้าแชนเนล
โมเดล --สตรีม--> แบ็กเอนด์ของคุณ|streamToChannel() --`trigger()`--> *Hibira|กระจายต่อ --WebSocket--> ทุกแท็บ ทุกคนในทีม
```

เราจึงใส่เครื่องมือคู่หนึ่งไว้ในแพ็กเกจ: `streamToChannel()` สำหรับแบ็กเอนด์ และ `useStream()` สำหรับเบราว์เซอร์ แบ็กเอนด์ส่งสตรีมของโมเดลให้ `streamToChannel()` ซึ่งจะเผยแพร่ข้อความเข้าแชนเนล คอมโพเนนต์ไหนก็ตามที่ subscribe แชนเนลนั้นจะได้ข้อความที่ค่อย ๆ ยาวขึ้นผ่าน `useStream()`

![เบราว์เซอร์สองตัวดูคำตอบ AI เดียวกันที่กำลังถูกเขียนไปพร้อมกัน: ซ้ายคือ Alice ที่ถาม ขวาคือ Bob](https://hibira.io/portal/articles/demo-shared-answer-th.webp)

ภาพนี้เราจับไว้ระหว่างทดสอบบนเครื่อง: Alice กด "ถาม AI" แล้วคำตอบก็ปรากฏบนหน้าจอของ Alice และ Bob พร้อมกัน ฝั่ง Bob ไม่ต้องทำอะไรเลย

### ไม่ใช่หนึ่งข้อความต่อหนึ่งโทเค็น

โมเดลเขียนได้หลายสิบโทเค็นต่อวินาที ถ้าเผยแพร่หนึ่งข้อความต่อโทเค็น คำตอบยาวหน่อยก็กลายเป็นพันข้อความ โควตาข้อความหมดเร็ว และเบราว์เซอร์ฝั่งรับก็ต้องเรนเดอร์ใหม่เป็นพันครั้ง

`streamToChannel()` จะเก็บโทเค็นไว้ก่อนแล้วเผยแพร่ทุก 100 มิลลิวินาที ไม่ว่าโมเดลจะเร็วแค่ไหน แชนเนลจะได้รับประมาณสิบข้อความต่อวินาที และข้อความบนหน้าจอก็ยังดูไหลต่อเนื่อง

```text
bars: จำนวนข้อความที่เผยแพร่ สำหรับ 1,000 โทเค็นที่ 50 โทเค็นต่อวินาที (วัดจริง)
unit: ข้อความ
หนึ่งข้อความต่อโทเค็น: 1000
*streamToChannel() หนึ่งข้อความทุก 100 มิลลิวินาที: 203
```

ยิ่งโมเดลเร็ว ยิ่งประหยัดมาก: 1,000 โทเค็นเท่าเดิมที่ 100 โทเค็นต่อวินาทีใช้เพียง 103 ข้อความ แต่ละข้อความยังมีขนาดไม่เกิน 3,500 ไบต์ ต่ำกว่าขีดจำกัด 4 KB ต่อข้อความของแผน Free และเมื่อแบ่งข้อความยาว ๆ ก็จะไม่ตัดกลางตัวอักษรไทยหรืออีโมจิ

### ลำดับ การหลุดการเชื่อมต่อ และคนที่เข้ามากลางทาง

ทุกข้อความมีหมายเลขลำดับ `useStream()` ต่อข้อความตามลำดับนั้น ถ้ามาสลับลำดับก็จะรอข้อความที่ขาดก่อนแล้วค่อยต่อ

ข้อความสุดท้ายมีเครื่องหมาย "เสร็จแล้ว" และแนบคำตอบฉบับเต็มไว้ด้วยถ้าใส่ในข้อความเดียวได้ นี่ทำไว้สำหรับคนที่เข้ามากลางทาง Bob เปิดหน้าตอนคำตอบเขียนไปได้ครึ่งหนึ่งแล้ว เขาไม่เห็นข้อความช่วงแรก แต่ทันทีที่ข้อความสุดท้ายมาถึง หน้าจอของเขาจะเปลี่ยนเป็นคำตอบฉบับสมบูรณ์ คนที่การเชื่อมต่อหลุดไปชั่วครู่ก็เช่นกัน

```text
sequence: Bob เข้ามาตอนคำตอบเขียนไปได้ครึ่งหนึ่ง
แบ็กเอนด์ของคุณ | *Hibira | Alice | Bob
1 > 2: ข้อความ 0–5 | ข้อความช่วงแรก
2 > 3: ข้อความ 0–5
note: Bob เพิ่งเปิดหน้าและ subscribe ตอนนี้
1 > 2: ข้อความ 6–9
2 > 3: ข้อความ 6–9
2 > 4: ข้อความ 6–9 | เห็นแค่ครึ่งหลัง
1 > 2: ข้อความ 10: เสร็จ + คำตอบเต็ม
2 > 3: เสร็จ
2 > 4: คำตอบเต็ม | หน้าจอของ Bob ครบถ้วน
```

ถ้าคำตอบยาวเกินกว่าข้อความสุดท้ายจะใส่ได้ทั้งหมด คนที่เข้ามากลางทางจะเห็น `incomplete: true` ซึ่งคุณดึงคำตอบที่บันทึกไว้จาก API ของคุณได้หนึ่งครั้ง เมื่อโมเดลผิดพลาดหรือถูกหยุด ผู้ subscribe จะได้รับ `error` ด้วย ไม่ค้างอยู่ที่ "กำลังสร้าง" ตลอดไป

## เขียนเป็นโค้ดได้แบบนี้

### React

```jsx
import { HibiraProvider, useEvent, usePresence, useStream } from '@hibira-io/react';

export function App() {
  return (
    <HibiraProvider appKey="your app key" options={{ channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } }}>
      <Room id="spec-42" />
    </HibiraProvider>
  );
}

function Room({ id }) {
  const { members } = usePresence(`presence-room-${id}`);
  const { text, done } = useStream(`private-room-${id}-ai`);
  useEvent(`private-room-${id}`, 'comment.created', (comment) => addComment(comment));
  return (
    <>
      <Avatars members={members} />
      <Answer text={text} loading={!done} />
    </>
  );
}
```

แบ็กเอนด์ (Node.js เฟรมเวิร์กไหนก็ได้) ส่งสตรีมของโมเดลให้ `streamToChannel()` ได้ตามที่เป็น มันรู้จักรูปแบบสตรีมของ Anthropic, OpenAI, Gemini และ Vercel AI SDK:

```js
import Pusher from 'pusher';
import { streamToChannel } from '@hibira-io/react/server';

const pusher = new Pusher({ appId, key, secret, host: 'ws.hibira.io', useTLS: true });
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages });
await streamToChannel(pusher, `private-room-${id}-ai`, stream, { meta: { asker: user.name } });
```

### Vue

ใช้ได้กับ Vue 3 และ Vue 2.7 ชื่อแชนเนลเป็น ref หรือ getter ได้ เมื่อพารามิเตอร์ของ route เปลี่ยน การ subscribe ก็เปลี่ยนตามเอง:

```js
// main.js
createApp(App)
  .use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
  .mount('#app');
```

```vue
<script setup>
import { usePresence, useStream } from '@hibira-io/vue';

const props = defineProps({ id: String });
const { members, count } = usePresence(() => `presence-room-${props.id}`);
const { text, done } = useStream(() => `private-room-${props.id}-ai`);
</script>
```

### Nuxt

บน Nuxt 3 และ Nuxt 4 แค่เพิ่มการตั้งค่าโมดูลบรรทัดเดียว composables จะถูก import อัตโนมัติ และใน server route ก็ใช้ `useHibiraServer()` กับ `streamToChannel()` ได้ทันที:

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@hibira-io/nuxt'],
});
```

```ts
// server/api/ask.post.ts
export default defineEventHandler(async (event) => {
  const { roomId, question } = await readBody(event);
  const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
  event.waitUntil(streamToChannel(useHibiraServer(event), `private-room-${roomId}-ai`, stream));
  return { ok: true };
});
```

โปรเจกต์ที่ยังใช้ Nuxt 2 ก็ไม่ถูกลืม: Nuxt 2.16 ขึ้นไปทำงานบน Vue 2.7 จึงใช้โมดูล `@hibira-io/nuxt/nuxt2` ได้

## เราทดสอบอย่างไร

ทั้งสามแพ็กเกจมีขนาดเล็กและไม่มี dependency อื่นนอกจาก pusher-js แต่มันต้องทำงานในหลายสภาพแวดล้อม การทดสอบจึงครอบคลุมกว้างกว่าตัวโค้ดมาก:

- รัน unit test บน React 17, 18, 19 และ Vue 3.5, Vue 2.7 ทีละเวอร์ชัน
- End-to-end test: ไคลเอนต์สองตัวเชื่อมต่อกับเซิร์ฟเวอร์จริง ตรวจการเข้าและออกของ presence, client event และสตรีม 200 โทเค็นที่ต้องประกอบกลับครบทั้งสองฝั่ง
- สร้างแอปเล็ก ๆ บน Nuxt 4.6, 3.21 และ 2.18 อย่างละตัว เปิดใน Chromium เป็นผู้ใช้สองคน ตรวจการเชื่อมต่อ, presence, อีเวนต์จากเซิร์ฟเวอร์ และข้อความที่สตรีมมา โดยไม่มี error ในคอนโซลและไม่มีคำเตือน hydration

## เริ่มต้นใช้งาน

```sh
npm install @hibira-io/react pusher-js   # React
npm install @hibira-io/vue pusher-js     # Vue 3 / Vue 2.7
npx nuxi module add @hibira-io/nuxt      # Nuxt 3 / 4
```

ก่อนเริ่ม ตั้งค่าบางอย่างในคอนโซลของ Hibira ไว้ก่อน:

- **สร้างแอปและเก็บข้อมูลรับรองไว้** App ID และ key ดูได้ตลอดในหน้า Overview ของแอป ส่วน secret จะแสดงเพียงครั้งเดียวตอนสร้างแอป ให้เก็บลงตัวแปรสภาพแวดล้อมของแบ็กเอนด์ทันที (สำหรับ Nuxt คือ `NUXT_PUBLIC_HIBIRA_KEY`, `NUXT_HIBIRA_APP_ID`, `NUXT_HIBIRA_SECRET`) key เปิดเผยได้ แต่ secret ต้องอยู่บนเซิร์ฟเวอร์เท่านั้น
- **เพิ่ม allowed origins** แผน Free ใช้ `*` ไม่ได้ ต้องเพิ่มที่อยู่เว็บไซต์ของคุณ (ตอนพัฒนา เช่น `http://localhost:3000`) ใน Allowed origins ไม่อย่างนั้นเบราว์เซอร์จะเชื่อมต่อไม่ได้
- **เตรียม route สำหรับการอนุญาต** แชนเนลแบบ private และ presence ต้องมี `/api/hibira/auth` ที่แบ็กเอนด์ คอยตรวจว่าใครล็อกอินอยู่และเซ็นการ subscribe ให้ ใน README ทั้งสามมีตัวอย่างที่คัดลอกไปใช้ได้เลย
- **4 KB ต่อข้อความ** บนแผน Free แต่ละอีเวนต์มีขนาดได้ไม่เกิน 4 KB `streamToChannel()` คุมขนาดให้อยู่แล้ว แต่อีเวนต์ที่คุณเผยแพร่เองต้องระวังขนาดด้วย

ถ้าอยากลองทำหน้าจอของ Alice และ Bob ด้วยตัวเอง ให้ใช้เบราว์เซอร์สองตัว หรือหน้าต่างปกติกับหน้าต่างส่วนตัว (incognito) และล็อกอินคนละผู้ใช้ สองแท็บในเบราว์เซอร์เดียวกันมักเป็นผู้ใช้คนเดียวกัน และ presence จะนับเป็นคนเดียว

ทั้งสามแพ็กเกจใช้สัญญาอนุญาต MIT และมีซอร์สโค้ดบน GitHub: [hibira-io/react](https://github.com/hibira-io/react), [hibira-io/vue](https://github.com/hibira-io/vue), [hibira-io/nuxt](https://github.com/hibira-io/nuxt) มันพูดโปรโตคอล Pusher จึงใช้กับเซิร์ฟเวอร์ที่เข้ากันได้ตัวไหนก็ได้ ถ้าโปรเจกต์ของคุณใช้ Laravel Echo อยู่แล้ว ส่ง `echo.connector.pusher` เข้าไปก็ใช้การเชื่อมต่อเดียวกับ Echo ได้

แผน Free ไม่ต้องใช้บัตรเครดิต ถ้าคุณกำลังจะเพิ่มผู้ช่วย AI ให้ผลิตภัณฑ์ หรือมี `useEffect` ที่เขียนเองจัดการข้อความเรียลไทม์อยู่เต็มไปหมด ลองเปลี่ยนสักหน้าหนึ่งมาใช้ hooks เหล่านี้ดู ถ้ามีไอเดียอะไร เปิด issue บน GitHub มาบอกเราได้เลย
