Hooks & State
Deep dive into useThread, useThreadList, and related headless state hooks.
All headless hooks must run inside ChatProvider.
Use useThread() for the active conversation and useThreadList() for thread navigation. Most custom UIs need both.
Start with ChatProvider
import {
ChatProvider,
openAIMessageFormat,
openAIReadableStreamAdapter,
} from "@openuidev/react-headless";
export function App() {
return (
<ChatProvider
apiUrl="/api/chat"
threadApiUrl="/api/threads"
streamProtocol={openAIReadableStreamAdapter()}
messageFormat={openAIMessageFormat}
>
<MyCustomChat />
</ChatProvider>
);
}That provider owns the shared state. The hooks below read from and write to that state.
useThread()
Use useThread() for the currently selected conversation: messages, send state, loading state, and message mutations.
const {
messages,
isRunning,
isLoadingMessages,
threadError,
processMessage,
cancelMessage,
appendMessages,
updateMessage,
setMessages,
deleteMessage,
} = useThread();Common send flow
function Composer() {
const { processMessage, cancelMessage, isRunning } = useThread();
const [input, setInput] = useState("");
return (
<form
onSubmit={(event) => {
event.preventDefault();
if (!input.trim() || isRunning) return;
processMessage({ role: "user", content: input });
setInput("");
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
{isRunning ? (
<button type="button" onClick={cancelMessage}>
Stop
</button>
) : (
<button type="submit">Send</button>
)}
</form>
);
}Use isLoadingMessages to show a loading state when a saved thread is being hydrated, and use threadError to render request or load failures near the conversation surface.
useThreadList()
Use useThreadList() for the sidebar: thread loading, selection, creation, pagination, and thread-level mutations.
const {
threads,
isLoadingThreads,
threadListError,
selectedThreadId,
hasMoreThreads,
loadThreads,
loadMoreThreads,
switchToNewThread,
createThread,
selectThread,
updateThread,
deleteThread,
} = useThreadList();Common sidebar flow
function ThreadSidebar() {
const {
threads,
selectedThreadId,
hasMoreThreads,
isLoadingThreads,
loadMoreThreads,
switchToNewThread,
selectThread,
deleteThread,
} = useThreadList();
return (
<aside>
<button onClick={switchToNewThread}>New chat</button>
{threads.map((thread) => (
<div key={thread.id}>
<button
onClick={() => selectThread(thread.id)}
aria-pressed={thread.id === selectedThreadId}
>
{thread.title}
</button>
<button onClick={() => deleteThread(thread.id)}>Delete</button>
</div>
))}
{hasMoreThreads ? (
<button onClick={() => loadMoreThreads()} disabled={isLoadingThreads}>
Load more
</button>
) : null}
</aside>
);
}switchToNewThread() clears the current selection so the next user message starts a new conversation. updateThread() is useful when you want to rename or otherwise patch thread metadata after creation.
Selectors
Use selectors to minimize re-renders when you only need a small part of the store.
const messages = useThread((state) => state.messages);
const selectedThreadId = useThreadList((state) => state.selectedThreadId);This is especially useful when your sidebar and message list are separate components and you do not want unrelated state updates to rerender both.