React AI chat component, with hooks — @aparte/react
@aparte/react wraps @aparte/core for React 18/19: an ergonomic <AparteChat> component, hooks
for state and the client, typed JSX for every element, and a generic <AparteUi> escape hatch.
npm install @aparte/react @aparte/core react react-dom@aparte/core, react and react-dom are peer dependencies.
<AparteChat> + useAparteChat
Section titled “<AparteChat> + useAparteChat”The useAparteChat hook owns the message state and the component ref, so you just spread them:
import { AparteChat, useAparteChat } from '@aparte/react';import '@aparte/core/styles.css';
export function Chat() { const chat = useAparteChat(); return ( <AparteChat ref={chat.ref} messages={chat.messages} onMessagesChange={chat.setMessages} emptyState={<p>Ask me anything…</p>} centerWhenEmpty /> );}The user’s message is appended to the thread automatically on send — don’t add it yourself.
onMessageSent is optional and fires after that append, for side-effects only (scroll, analytics,
a backend call).
Slots are plain props: emptyState, composer, aboveComposer,
toolbar (the composer’s bottom row — mode picker, model selector: see
The composer toolbar for an example), and
renderBubble for a fully custom bubble — driven by the reactive messages list, so re-render
from message.content / message.segments and it streams live (details).
The imperative handle (chat.ref) exposes streaming, branch/edit and scrollToBottom.
The other five callbacks — onAction, onMessagesChange, onMessageAppended, onTypingChange, onConversationCreated — take the same payloads as everywhere else; the table with all four frameworks side by side is generated from this wrapper’s own props: Wrapper surface.
Wiring a real model
Section titled “Wiring a real model”The wrapper is provider-agnostic. Register a provider + transport once (see
Providers) and mount an AparteClient with useAparteClient — it bridges composer
sends to the model:
import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core';import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';import { useAparteClient } from '@aparte/react';
aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENROUTER));aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
function Chat() { useAparteClient(); // streams replies from the configured provider // …<AparteChat /> as above}Pass a per-instance config prop to scope providers/transport to a single <AparteChat> instead of
aparteGlobalConfig.
The <aparte-elicitation> presenter — what the built-in approval gate and requestUserInput()
ask through — renders inside the host by default, as it does in <aparte-chat>; pass
elicitation={false} when you register a presenter of your own. The root element
([data-aparte-chat]) takes className and style, merged after core’s own class, so a
utility-first app sizes the chat column from JSX:
<AparteChat className="flex-1 min-h-0" messages={messages} onMessageSent={send} />Any aparté element: typed JSX
Section titled “Any aparté element: typed JSX”The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react — nothing to
register. Attribute names are the HTML ones, and a typo or a wrong value type is a compile error:
<aparte-select searchable="" placeholder="Pick a model"> <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option></aparte-select>Presence attributes are '', not true — React stringifies what it sets on a custom element, so
searchable={false} would render searchable="false" and an element testing hasAttribute reads
that as on. Events reach you by ref and are typed through the DOM. The rules and the full set are on
Placing elements, typed.
Any OTHER element: <AparteUi>
Section titled “Any OTHER element: <AparteUi>”For an element aparté does not define — one of yours, or a third party’s:
import { AparteUi } from '@aparte/react';
<AparteUi name="my-token-counter" props={{ 'data-budget': '8000' }} onElementEvent={onEvent} />It mounts any tag name, which is exactly what you want for a foreign element and exactly what you do not need for aparté’s own — those are typed above.
Also exported
Section titled “Also exported”useConversationManager— React-state view over the coreAparteConversationManager(list / create / archive), for a multi-conversation sidebar.
Testing it
Section titled “Testing it”Vitest, Jest — every runner — executes on Node, so @aparte/core resolves to its DOM-free
entry and no <aparte-*> element upgrades under jsdom: the tag stays a plain
HTMLElement and every assertion about it fails for a reason nothing explains. Alias the
specifier to @aparte/core/browser, the
entry with the elements in it.