ומה אם שתי קומפוננטות מקשיבות לאותו SSE ?
כשהייתי צריך לכתוב קומפוננטת ריאקט שמקשיבה ל Server Sent Events ומעדכנת את התצוגה לפי האירוע שמגיע האינטואיציה הראשונה שלי היתה להוסיף אפקט:
useEffect(() => {
const evtSource = new EventSource("/notifications");
const handler = (e: MessageEvent) => {
setPosts(p => [...p, JSON.parse(e.data)]);
};
evtSource.addEventListener('message', handler);
return () => {
evtSource.close();
}
}, []);
ואז העולם הגיע והיה צריך לעדכן קומפוננטה נוספת כשרשימת הפוסטים מתעדכנת. עכשיו מה עושים?
התשובה כשצריך לשתף מידע בין קומפוננטות היא תמיד זהה - שומרים את המידע במקום מרכזי ברמת האפליקציה (מה שנקרא Store) וכל קומפוננטה לוקחת משם את המידע.
דרך אחת לבנות State גלובאלי לאפליקציה היא לשלב פריימוורק כמו רידאקס (או Jotai או preact-signals או zustand או אפילו מובאקס). פריימוורק לניהול סטייט מכריח אותי לכתוב את הקוד שמתחבר ל SSE Endpoint ולהחליט איפה יישמר המידע ומתי מפסיקים להקשיב לאירועים.
דרך שנייה לבנות State גלובאלי היא לשים את המידע ב Context ולמשוך אותו דרך Hook. הרבה פעמים נראה את הפיתרון הזה ב Hooks מוכנים, למשל use-event-source. אני מדביק כאן את הקוד שלו כי זה מעניין:
import { createContext, useContext, useEffect, useState } from "react";
export interface EventSourceOptions {
init?: EventSourceInit;
event?: string;
}
export type EventSourceMap = Map<
string,
{ count: number; source: EventSource }
>;
const context = createContext<EventSourceMap>(
new Map<string, { count: number; source: EventSource }>(),
);
export const EventSourceProvider = context.Provider;
/**
* Subscribe to an event source and return the latest event.
* @param url The URL of the event source to connect to
* @param options The options to pass to the EventSource constructor
* @returns The last event received from the server
*/
export function useEventSource(
url: string | URL,
{ event = "message", init }: EventSourceOptions = {},
) {
let map = useContext(context);
let [data, setData] = useState<string | null>(null);
useEffect(() => {
let key = [url.toString(), init?.withCredentials].join("::");
let value = map.get(key) ?? {
count: 0,
source: new EventSource(url, init),
};
++value.count;
map.set(key, value);
value.source.addEventListener(event, handler);
// rest data if dependencies change
setData(null);
function handler(event: MessageEvent) {
setData(event.data || "UNKNOWN_EVENT_DATA");
}
return () => {
value.source.removeEventListener(event, handler);
--value.count;
if (value.count <= 0) {
value.source.close();
map.delete(key);
}
};
}, [url, event, init, map]);
return data;
}
ה Hook יוצר מפה גלובאלית של Event Listeners שמקשיבים לאירועים לפי ה URL. כל פעם שקומפוננטה צריכה להתחבר ל SSE Endpoint הנתיב מצטרף למפה ואם הוא כבר קיים שם אז ערך ה count
עולה ב-1. כשאף קומפוננטה לא מקשיבה לאירועים ה SSE נסגר ונמחק מהמפה.
השימוש ב Hook נראה כך (מתוך הדוגמה שלהם ב Readme):
// app/components/counter.ts
import { useEventSource } from "remix-utils/sse/react";
function Counter() {
// Here `/sse/time` is the resource route returning an eventStream response
let time = useEventSource("/sse/time", { event: "time" });
if (!time) return null;
return (
<time dateTime={time}>
{new Date(time).toLocaleTimeString("en", {
minute: "2-digit",
second: "2-digit",
hour: "2-digit",
})}
</time>
);
}
עכשיו בחזרה להתלבטות שלנו - האם לנהל את המידע שנשמר בצד לקוח בצורה מובלעת בתוך ה Hooks או בפריימוורק לניהול סטייט צד לקוח מסודר? כמו תמיד בשאלות האלה אין תשובה חד משמעית. הנטייה שלי היא לבחור בפריימוורק צד לקוח כדי לקבל שליטה טובה יותר בתזמונים. בדוגמה של אירועי Server Sent Events קל לדמיין שאני ארצה להמשיך להקשיב לאירועים גם בלי קומפוננטה על המסך. למשל אם קומפוננטה מציגה רשימת עדכונים ומשתמש עובר למסך אחר, אז כשאני חוזר למסך רשימת העדכונים אני אראה שם את העדכונים שנשלחו גם בזמן שהייתי במסך האחר. יחד עם זאת אין ספק שבמקרים פשוטים ה Hook במיוחד כזה שפשוט מדביקים מהאינטרנט חוסך כתיבת קוד ויכול לקצר זמני פיתוח.
מה דעתכם? איזה גישה עבדה לכם טוב יותר?