Skip to content

Custom Event Handling

By default, only text_delta events are processed (appended as text parts to the assistant message). Use onEvent to handle additional event types.

const { messages } = useChat({
api: "/chat",
onEvent: (event, helpers) => {
switch (event.type) {
case "text_delta":
helpers.appendText(event.delta);
break;
case "tool_call":
helpers.appendPart({
type: "tool_call",
tool_name: event.tool_name,
argument: event.argument,
});
break;
}
},
});

The helpers object provides three methods:

Append text to the last text part of the current assistant message. If the last part is not a text part, a new text part is created:

helpers.appendText(event.delta);

Append a new content part to the current assistant message. The type of part matches the generic parameter passed to useChat:

helpers.appendPart({
type: "tool_call",
tool_name: event.tool_name,
argument: event.argument,
});

Full access to the React state setter for advanced mutations. Use this when you need to modify messages in ways that appendText and appendPart don’t support:

helpers.setMessages((prev) => {
const last = prev[prev.length - 1];
// Custom mutation logic
return [...prev.slice(0, -1), { ...last, parts: [{ type: "text", text: "replaced" }] }];
});

Each message contains a parts array. Render them by switching on part.type:

{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{msg.parts.map((part, i) => {
switch (part.type) {
case "text":
return <span key={i}>{part.text}</span>;
case "tool_call":
return (
<pre key={i} style={{ opacity: 0.7, fontSize: "0.85em" }}>
[Tool: {part.tool_name}]{"\n"}
{JSON.stringify(JSON.parse(part.argument), null, 2)}
</pre>
);
default:
return null;
}
})}
</div>
))}

The hook accepts a second generic parameter TEvent for custom SSE event types. Your backend might send events beyond text_delta and tool_call — for example, error, thinking, or status events. Define your custom event types and pass them as the second generic:

import { useChat } from "@devscalelabs/react-sse-chat";
import type { SSEEvent } from "@devscalelabs/react-sse-chat";
// Define your custom event
interface ErrorEvent {
type: "error";
message: string;
}
// Union with the built-in events
type MyEvent = SSEEvent | ErrorEvent;
function Chat() {
const { messages } = useChat<ContentPart, MyEvent>({
api: "/chat",
onEvent: (event, helpers) => {
switch (event.type) {
case "text_delta":
helpers.appendText(event.delta);
break;
case "tool_call":
helpers.appendPart({
type: "tool_call",
tool_name: event.tool_name,
argument: event.argument,
});
break;
case "error":
// event.message is fully typed here
helpers.appendText(`[Error: ${event.message}]`);
break;
}
},
});
}

Without the TEvent generic, TypeScript would reject case "error" because SSEEvent only includes "text_delta" | "tool_call".

The first generic parameter TPart lets you define custom content part types. Your custom type must extend { type: string }:

import { useChat } from "@devscalelabs/react-sse-chat";
import type { ContentPart, SSEEvent } from "@devscalelabs/react-sse-chat";
// Define custom part and event
interface ThinkingPart {
type: "thinking";
text: string;
}
interface ThinkingEvent {
type: "thinking";
text: string;
}
type MyPart = ContentPart | ThinkingPart;
type MyEvent = SSEEvent | ThinkingEvent;
function Chat() {
const { messages } = useChat<MyPart, MyEvent>({
api: "/chat",
onEvent: (event, helpers) => {
switch (event.type) {
case "text_delta":
helpers.appendText(event.delta);
break;
case "tool_call":
helpers.appendPart({
type: "tool_call",
tool_name: event.tool_name,
argument: event.argument,
});
break;
case "thinking":
helpers.appendPart({
type: "thinking",
text: event.text,
});
break;
}
},
});
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
{msg.parts.map((part, i) => {
switch (part.type) {
case "text":
return <span key={i}>{part.text}</span>;
case "tool_call":
return (
<pre key={i} style={{ opacity: 0.7, fontSize: "0.85em" }}>
[Tool: {part.tool_name}]{"\n"}
{JSON.stringify(JSON.parse(part.argument), null, 2)}
</pre>
);
case "thinking":
return <details key={i}><summary>Thinking...</summary>{part.text}</details>;
default:
return null;
}
})}
</div>
))}
</div>
);
}

Both generics are optional and default to the built-in types. All callbacks (onMessage, onFinish) and the returned messages array are fully typed with your custom part type.

In addition to onEvent, you can use these callbacks for observing the chat lifecycle:

Called when a complete message is added (both user and assistant):

useChat({
api: "/chat",
onMessage: (message) => {
console.log(`New ${message.role} message:`, message.parts);
},
});

Called when the stream ends with the full message array:

useChat({
api: "/chat",
onFinish: (messages) => {
console.log("Stream complete. Total messages:", messages.length);
},
});

Called when a fetch or stream error occurs:

useChat({
api: "/chat",
onError: (error) => {
console.error("Chat error:", error.message);
},
});