Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/usechat-lazy-client-instance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-react': patch
---

`useChat` now constructs its internal `ChatClient` via a `useState` lazy initializer instead of `useMemo`. `useMemo` is documented as a performance hint that React may discard and recompute, which could spuriously build a second client (each owns a `StreamProcessor`, a devtools bridge, and a connection); `useState`'s initializer is a per-mount "runs once" guarantee. The client is still recreated synchronously when `id` changes, via the "adjust state during render" pattern. Also removes an unused internal ref. No public API or behavior change.
29 changes: 23 additions & 6 deletions packages/ai-react/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,29 @@ export function useChat<
const messagesRef = useRef<Array<UIMessage<TTools>>>(
options.initialMessages || [],
)
const isFirstMountRef = useRef(true)
const activeClientRef = useRef<ChatClient | null>(null)
const cleanupInvalidationRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)

// Update ref synchronously during render so it's always current when useMemo runs.
// Update ref synchronously during render so it's always current when the
// client is created (createClient runs during render via the useState lazy
// initializer / the clientId-reset below).
messagesRef.current = messages

// Track current options in a ref to avoid recreating client when options change
const optionsRef = useRef<UseChatOptions<TTools, TSchema, TContext>>(options)
optionsRef.current = options

// Create ChatClient instance with callbacks to sync state
const client = useMemo(() => {
// Create the ChatClient eagerly, but exactly once per `clientId`.
// `useState`'s lazy initializer runs once per mount (a semantic guarantee),
// whereas `useMemo` is only a performance hint React may discard and
// recompute — which would spuriously construct a second ChatClient (it owns
// a StreamProcessor, a devtools bridge, and a connection). When `clientId`
// changes we swap synchronously via the "adjust state during render"
// pattern; the `[client]` effect below disposes the superseded instance.
const createClient = (): ChatClient<TTools, TContext> => {
const messagesToUse = options.initialMessages || []
isFirstMountRef.current = false

// Build options with conditional spreads for fields whose source
// type is `T | undefined` but the ChatClient target uses a strict
Expand Down Expand Up @@ -159,7 +165,18 @@ export function useChat<
})
activeClientRef.current = instance
return instance
}, [clientId])
}

const [client, setClient] =
useState<ChatClient<TTools, TContext>>(createClient)
const [trackedClientId, setTrackedClientId] = useState(clientId)
if (clientId !== trackedClientId) {
// `clientId` changed (e.g. `options.id` was swapped): build a fresh client
// for the new identity during this render. React re-renders immediately,
// and the `[client]` effect disposes the previous instance.
setTrackedClientId(clientId)
setClient(createClient())
}

useEffect(() => {
const clientMessages = client.getMessages()
Expand Down