How I built @grodev/claude-chat-react — architecture notes

typescript dev.to

TL;DR

A drop-in React chat widget on the Claude API — @grodev/claude-chat-react — with a headless hook (useClaudeStream) if you want to bring your own UI, or a <ClaudeChat/> component if you don't. 8 KB gzipped, zero runtime deps beyond React. Companion project to my vanilla-JS widget — same wire contract, same PHP proxy, different frontend stack.

This is a notes-from-the-build post: three decisions that shaped the API, and one thing I audited line-by-line because I don't trust the model with it.

Decision 1: Two ways in, not one

Every React library eventually gets asked "can I use just the state, not the UI?" — so I built both from day one:

// Drop-in — 5 lines
import { ClaudeChat } from '@grodev/claude-chat-react';
<ClaudeChat endpoint="/api/chat" accentColor="#1D9E75" />

// Headless — bring your own UI
import { useClaudeStream } from '@grodev/claude-chat-react';
const { messages, status, sendMessage } = useClaudeStream({ endpoint: '/api/chat' });
Enter fullscreen mode Exit fullscreen mode

The hook does the streaming, state, and abort control; the component adds layout, dark mode, and a11y on top. Consumers who need a different look pay zero cost — they import only the hook and ~2 KB drops out of the bundle.

Decision 2: The API key never touches the browser (and I mean never)

Central design choice — the widget calls your server-side proxy, never api.anthropic.com directly. If you look at the source, there is no place where you can accidentally pass an API key as a prop. It's not in the type definitions. It's not a hidden config. The escape hatch does not exist.

This is the boring kind of security: not a validator that checks for keys, but an API shape where the wrong pattern isn't representable. Companion repo claude-chat-widget ships a working PHP proxy with the exact wire contract this library expects — copy it, deploy anywhere PHP runs, done.

Decision 3: Streaming with a real abort

The streaming loop looks like a hundred other SSE readers on GitHub. Three things I got specifically right:

  1. AbortController lives in useRef, not useState. State would re-render every send, and downstream components would blink.
  2. setMessages((prev) => ...) — never closure-based. Tokens arriving during a re-render need current state, not stale state, or you'll see torn text.
  3. if (payload.error) throw — routes upstream errors through the same catch, so onError fires once with a real Error, not on each malformed frame.

The a11y I refuse to leave to Claude

When I let the model draft the JSX, it produced a working panel. It also produced a working panel without any of these:

  • role="dialog" on the panel
  • aria-live="polite" on the message log
  • aria-expanded on the launcher
  • Focus management (input focus on panel open, escape close)
  • Enter sends, Shift+Enter inserts a newline

Every one of these is in the final code. Every one was added by me after the draft, not before. LLMs don't skip a11y out of malice — they skip it because most tutorial code they trained on skipped it. If you don't audit specifically for the boring accessibility bits, you don't get them.

That pattern generalizes: audit the AI's work for what the training data underrepresents, not for what the model gets "wrong."

What's not in the package

Deliberately out of scope, documented in the README:

  • Rate limiting → add on your proxy
  • Retrieval-augmented context → your job; feed the system prompt from your data
  • Session persistence → the hook exposes messages, you decide where to store

Each of those turns a demo into a product, and each one is where your particular use case matters more than a shared abstraction.

Full write-up

Project decisions, human-vs-AI split, and the security checklist I run before every proxy change are in CLAUDE.md in the repo. Or the broader post about how I document AI-first workflow across all six of my repos.


I'm Dominik Groński / GroDev — a new studio in Poznań, Poland (JDG since May 2026), available for first paid deployments. This library is MIT.

Source: dev.to

arrow_back Back to Tutorials