Chat widgets look deceptively simple — messages in a box, a text input, a send button. Then you actually try to build one and discover all the things nobody talks about: the auto-open timing so you don’t annoy users, the scroll contexts (the chat window scrolls independently from the page), the race condition when the user manually opens the chat right as the auto-open timer fires, the session ID that has to persist across page navigations without leaking between browser tabs.
This tutorial walks through src/components/aiChat.tsx — a self-contained React component that integrates with any webhook-based AI service, renders Markdown responses, and handles all of those edge cases. I’ll explain every decision along the way.
What You Need Before Starting
Knowledge:
- React fundamentals — components, props, state, effects
- TypeScript interfaces and types
async/awaitand the Fetch API- What
sessionStorageis and how it differs fromlocalStorage
Dependencies:
npm install react-markdown remark-gfm lucide-react
react-markdown renders Markdown safely to React elements. remark-gfm adds GitHub Flavored Markdown support — tables, strikethrough, task lists. lucide-react provides the chevron icon for the collapse button.
A webhook to test against:
curl -X POST https://your-webhook-url.com \
-H "Content-Type: application/json" \
-d '{"action": "sendMessage", "sessionId": "test-123", "chatInput": "Hello"}'
# Expected: {"output": "Hello! How can I help you today?"}
Five Layers, One Component
Rather than a mermaid diagram, here’s how I think about the component’s responsibilities:
The visual layer controls whether the bubble or the full chat window is shown, and whether the widget hides entirely when the user scrolls near the page footer. The message layer holds the conversation history array, the current input value, and whether a request is in flight. The session layer generates a UUID once and stores whether the auto-open has already fired this tab session. The network layer handles the webhook POST, error parsing, and response normalization. The rendering layer applies Markdown to bot messages, applies inline styles for portability, and manages focus.
Defining the Props and State
Props:
interface ChatBubbleProps {
webhookUrl: string;
initialBotMessage?: string;
autoOpenDelay?: number;
botName?: string;
userName?: string;
bubbleIcon?: React.ReactNode;
closeIcon?: React.ReactNode;
sendIcon?: React.ReactNode;
placeholder?: string;
headerText?: string;
openIcon?: React.ReactNode;
hideNearBottomOffset?: number;
}
Only webhookUrl is required. I used React.ReactNode for the icon props rather than string because it lets callers pass emoji strings, JSX elements like <ChatIcon />, or even <img> tags — no wrapping needed.
State:
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [sessionId] = useState(() => crypto.randomUUID());
const [isNearBottom, setIsNearBottom] = useState(false);
sessionId uses lazy initialization — the function inside useState(() => ...) runs exactly once on mount. I destructure without a setter because the session ID should never change during the component’s lifetime.
The Message type is intentionally flat:
interface Message {
id: number;
sender: "user" | "bot";
text: string;
}
Using Date.now() for IDs works for most cases, but if you ever fire two messages in the same millisecond you’ll get a collision. crypto.randomUUID() would be safer for production.
Session ID and Why It Matters
Without a session ID, every message you send is treated as a new conversation by the AI backend. Follow-up questions don’t work. The AI has no context.
const payload = {
action: "sendMessage",
sessionId: sessionId, // same UUID for every message in this component instance
chatInput: trimmedInput,
};
On the backend side (n8n, a custom API, whatever), the session ID lets you look up the conversation history and pass it to the model:
const sessions = new Map();
app.post('/webhook', async (req, res) => {
const { sessionId, chatInput } = req.body;
let history = sessions.get(sessionId) || [];
history.push({ role: 'user', content: chatInput });
const response = await ai.chat(history);
history.push({ role: 'assistant', content: response });
sessions.set(sessionId, history);
res.json({ output: response });
});
Auto-Open That Doesn’t Annoy People
The auto-open logic has one job: pop the chat open once per browsing session, not once ever, and not every page load.
const SESSION_STORAGE_KEY = "chatBubbleAutoOpened";
useEffect(() => {
setMessages([{ id: Date.now(), sender: "bot", text: initialBotMessage }]);
const hasAutoOpenedInSession = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!hasAutoOpenedInSession) {
const timer = setTimeout(() => {
setIsOpen((currentIsOpenState) => {
if (!currentIsOpenState) {
sessionStorage.setItem(SESSION_STORAGE_KEY, "true");
return true;
}
return currentIsOpenState;
});
}, autoOpenDelay);
return () => clearTimeout(timer);
}
}, [initialBotMessage, autoOpenDelay]);
The functional update inside setIsOpen is the key part. If the user manually opens the chat before the timer fires, currentIsOpenState will be true, and we skip marking it as auto-opened. This way the flag only gets set if the timer actually caused the open — not if the user did.
sessionStorage clears when the tab closes, so the auto-open fires once per tab session. If you used localStorage, it would never auto-open again after the first visit ever, which isn’t what you want.
Two Scroll Problems, Two Solutions
The component manages two completely independent scroll contexts.
Chat window auto-scroll — when new messages arrive, scroll the message list to the bottom:
const messageListRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
}
}, [messages]);
Setting scrollTop to scrollHeight puts the scroll position at the very bottom of the content. This runs whenever the messages array changes.
Page scroll detection — hide the widget when the user is near the footer:
useEffect(() => {
const handleScroll = () => {
if (typeof window !== "undefined" && hideNearBottomOffset > 0) {
const nearBottom =
window.scrollY + window.innerHeight >=
document.documentElement.scrollHeight - hideNearBottomOffset;
setIsNearBottom(nearBottom);
}
};
if (typeof window !== "undefined") {
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}
}, [hideNearBottomOffset]);
The passive: true flag tells the browser this listener won’t call preventDefault(). That lets the browser optimize scrolling without waiting for the JavaScript to finish — skipping it causes scroll jank on mobile. The cleanup function is critical: without it, the scroll listener keeps firing after the component unmounts, leading to setState calls on an unmounted component.
I chose to hide the widget near the footer because that’s typically where contact info and social links live. The chat bubble would cover exactly the content the user is trying to read.
Sending a Message
const handleSendMessage = async (event?: FormEvent) => {
if (event) event.preventDefault();
const trimmedInput = inputValue.trim();
if (!trimmedInput || isLoading) return;
const userMessage: Message = {
id: Date.now(),
sender: "user",
text: trimmedInput,
};
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputValue("");
setIsLoading(true);
const payload = {
action: "sendMessage",
sessionId: sessionId,
chatInput: trimmedInput,
};
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
let errorData: any = { message: `Request failed with status: ${response.status}` };
try {
errorData = await response.json();
} catch (parseError) {
/* ignore */
}
throw new Error(errorData?.message || errorData?.error || `Webhook request failed: ${response.status}`);
}
const data = await response.json();
const botResponseText = data.output || "Sorry, I didn't get a valid response.";
const botMessage: Message = {
id: Date.now() + 1,
sender: "bot",
text: botResponseText,
};
setMessages((prevMessages) => [...prevMessages, botMessage]);
} catch (error) {
console.error("Chat Error:", error);
const errorMessage: Message = {
id: Date.now() + 1,
sender: "bot",
text: `Sorry, an error occurred: ${error instanceof Error ? error.message : "Could not connect."}. Please try again later.`,
};
setMessages((prevMessages) => [...prevMessages, errorMessage]);
} finally {
setIsLoading(false);
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}
};
A few things I want to call out here. The user message is added to the UI immediately — before the fetch even starts. This is optimistic UI update, and it makes the chat feel snappy. If the request fails, the error message appears as a bot response, so the flow still makes sense.
The finally block is non-negotiable. Without it, if the fetch throws an exception, isLoading stays true forever. The input never re-enables. The user is stuck. Always reset loading state in finally, not in try.
I try to parse error details from the response body before falling back to the status code. Webhook services often put useful messages in the JSON, and showing the user "Webhook request failed: 500" is less helpful than "Rate limit exceeded".
Markdown in Bot Messages Only
Bot responses are rendered through react-markdown. User messages render as plain text — intentionally. You don’t want users to accidentally (or maliciously) inject Markdown into their own messages.
{msg.sender === "bot" ? (
<ReactMarkdown
children={msg.text}
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => <p style={styles.botMessageMarkdown_p} {...props} />,
a: ({ node, ...props }) => <a style={styles.botMessageMarkdown_a} {...props} target="_blank" rel="noopener noreferrer" />,
code: ({ node, inline, ...props }) => <code style={styles.botMessageMarkdown_code} {...props} />,
pre: ({ node, ...props }) => <pre style={styles.botMessageMarkdown_pre} {...props} />,
// ... other elements
}}
/>
) : (
msg.text
)}
react-markdown doesn’t render raw HTML by default, so <script>alert('xss')</script> becomes literal text, not executable code. That’s the behavior you want.
The custom component overrides exist because browser default styles for <p>, <ul>, <code>, etc. look wrong inside a chat bubble. The overrides control margins, link colors, and code block backgrounds without needing external CSS.
Why Inline Styles
The entire component uses a styles object with inline style properties rather than CSS classes. This means the component is completely self-contained — you can drop it into any project without worrying about class name conflicts or global CSS bleeding in. It also means dynamic styles based on props or state are trivial.
The tradeoffs: no :hover pseudo-classes, no @media queries, slightly larger bundle if used multiple times. For a floating widget that sits outside the normal page layout, the portability wins.
Putting It in Your Astro Page
---
import ChatBubble from '@/components/aiChat';
---
<ChatBubble
client:load
webhookUrl="https://your-n8n-instance.com/webhook/chat"
initialBotMessage="Hi! I'm Jason's AI assistant. Ask me anything about his work!"
botName="AI Assistant"
autoOpenDelay={5000}
hideNearBottomOffset={400}
/>
client:load is the Astro directive that hydrates the component on the client immediately. The component needs JavaScript to function — there’s no static render fallback.
One thing to watch: if you set hideNearBottomOffset to 0, the scroll detection is disabled entirely and the widget stays visible at all scroll positions. That might be what you want on a short page with no footer content to obscure.