Skip to content

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.

Terminal window
npm install @aparte/react @aparte/core react react-dom
npm install @aparte/provider-scenario # the scripted model, for the first run below, with no key

@aparte/core, react and react-dom are peer dependencies.

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 wrapper projects only these slots. Anything else you put inside <AparteChat> is discarded — React and Svelte reject it at compile time, Vue and Angular drop it at runtime. For full control of the input row, fill the composer slot; for the primitives as children, use core’s <aparte-chat> directly.

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.

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.

Start with the scripted model: it needs no key and no network, and every line below is the same for a real one.

import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core';
import { createScenarioProvider } from '@aparte/provider-scenario';
import { showcase } from '@aparte/provider-scenario/showcase';
import { useAparteClient } from '@aparte/react';
aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase }));
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
function Chat() {
useAparteClient(); // streams the scripted reply — no key, no network
// …<AparteChat /> as above
}

Swap the provider for a real one and nothing else changes:

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.

For a wait of your own — independent of what the client is doing, e.g. while your own store is still answering — pass loading:

<AparteChat loading={isFetchingHistory} />

See the wrapper reference for the same prop in Vue, Svelte and Angular.

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} />

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.

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.

  • useConversationManager — React-state view over the core AparteConversationManager (list / create / archive), for a multi-conversation sidebar.

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.

A complete chat site in React — the conversation sidebar, the header, the settings dialog, markdown, highlighting, tools, and the scripted model — is in this repository: apps/examples/react. It shares its setup with the vanilla, Vue, Svelte and Angular sites through apps/examples/_shared, so the framework-specific part is App.tsx and the main.tsx that mounts it — everything else is what an app writes in any framework.