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' });
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:
-
AbortControllerlives inuseRef, notuseState. State would re-render everysend, and downstream components would blink. -
setMessages((prev) => ...)— never closure-based. Tokens arriving during a re-render need current state, not stale state, or you'll see torn text. -
if (payload.error) throw— routes upstream errors through the same catch, soonErrorfires once with a realError, 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-expandedon the launcher - Focus management (input focus on panel open, escape close)
-
Entersends,Shift+Enterinserts 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.