Skip to content
kitn AI/UI

State helpers & hooks

The elements are controlled: you own the data, pass it in, listen for events. To make that ergonomic, @kitn.ai/ui/state ships immutable helpers and a streaming handle, and each framework gets a batteries-included store that owns the state for you.

Everything drives one shape — a functional-updater setter, (prev) => next. React’s setState, a Solid signal setter, and one line for plain HTML all satisfy it:

const set = (fn) => { el.messages = fn(el.messages ?? []); };

The helpers and the streaming handle accept any setter that conforms to this shape, so they work the same way regardless of which framework is in play.

Six pure functions for common mutations. Each takes the current array and returns a new one:

import {
appendMessage,
upsertMessage,
updateMessage,
removeMessage,
appendText,
addSuggestion,
removeSuggestion,
} from '@kitn.ai/ui/state';
// Append a user turn
setMessages((m) => appendMessage(m, { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text }] }));
// Patch a field on an existing message (e.g. correct the text after an edit)
setMessages((m) => updateMessage(m, id, { parts: [{ type: 'text', text: corrected }] }));
// Insert-or-replace by id (useful for streaming placeholders)
setMessages((m) => upsertMessage(m, { id, role: 'assistant', parts: [{ type: 'text', text: partial }] }));
// Append a delta onto an existing message's trailing text part (streaming primitive)
setMessages((m) => appendText(m, id, delta));

ChatMessage is exported from @kitn.ai/ui (and re-exported from @kitn.ai/ui/react) if you need the type:

import type { ChatMessage } from '@kitn.ai/ui';

createAssistantStream inserts an assistant placeholder and returns a handle with methods that grow it in place. Pass your setter; it does the rest.

import { createAssistantStream } from '@kitn.ai/ui/state';
const s = createAssistantStream(setMessages);
for await (const part of backend(prompt)) {
s.appendText(part);
}
s.done(); // marks the message finished

The full handle:

MethodPurpose
appendText(chunk)Append a text delta to the trailing text part
appendReasoning(chunk, opts?)Append a reasoning delta (opts can set label, provider index, or signature)
upsertTool(toolCallId, patch)Insert a tool part (first call) or merge patch onto its ToolPart (later calls), keyed by toolCallId
addCard(envelope)Insert a generative-UI card part (first call) or replace it in place on later calls, keyed by envelope.id
addSource(source)Append a citation part
addFile(attachment)Append a file/attachment part
done()Mark the message complete
abort(reason?)Mark the message aborted; flips any in-flight tool parts to output-error

Re-send a card with the same envelope.id to revise it in place — a card can fill in as the model works instead of stacking duplicates.

You can pass an optional init object (a Partial<ChatMessage>) as the second argument to seed the message before the stream starts — for example a stable id you generated up front so you can correlate it with your backend request:

const s = createAssistantStream(setMessages, { id: requestId });

useKaiChat is a React hook (from @kitn.ai/ui/react) that owns the message list, wires the submit handler, and returns a bind object you spread directly onto <Chat>:

import { Chat, useKaiChat } from '@kitn.ai/ui/react';
function App() {
const chat = useKaiChat({
async onSubmit({ value }) {
chat.append({ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: value }] });
const s = chat.streamAssistant();
for await (const part of backend(value)) s.appendText(part);
s.done();
},
});
return <Chat {...chat.bind} />;
}

chat.bind carries messages, onSubmit (the element event handler), and any other props the hook manages — spread it and you’re done. If you need to read or set the message list directly, chat.messages and chat.setMessages are also exposed.

For SolidJS, createKaiChat (from @kitn.ai/ui) returns the same shape. Because the element is a web component in a Solid template, you spread chat.bind as JSX props and attach the submit handler separately:

import { createKaiChat } from '@kitn.ai/ui';
const chat = createKaiChat({
async onSubmit({ value }) {
chat.append({ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: value }] });
const s = chat.streamAssistant();
for await (const part of backend(value)) s.appendText(part);
s.done();
},
});
<kai-chat {...chat.bind} on:kai-submit={chat.handleSubmit} />;

chat.handleSubmit is a stable reference to the event handler — pass it directly to the on:kai-submit prop rather than an inline arrow so Solid’s event delegation can clean it up.

Each useKaiChat / createKaiChat call creates its own independent state. There is no global store — instantiate as many as you need and they run in isolation:

const left = useKaiChat({ onSubmit: askLeft });
const right = useKaiChat({ onSubmit: askRight });
<Chat {...left.bind} />
<Chat {...right.bind} />

Without a framework hook, wire the setter contract manually and use the helpers directly:

import '@kitn.ai/ui/elements';
import { appendMessage, createAssistantStream } from '@kitn.ai/ui/state';
await customElements.whenDefined('kai-chat');
const el = document.querySelector('kai-chat');
const set = (fn) => { el.messages = fn(el.messages ?? []); };
el.addEventListener('kai-submit', async (e) => {
const id = crypto.randomUUID();
set((m) => appendMessage(m, { id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: e.detail.value }] }));
const s = createAssistantStream(set);
for await (const part of backend(e.detail.value)) s.appendText(part);
s.done();
});