From d5cf35b8ad86b110e07fb926dd0915a826da1f96 Mon Sep 17 00:00:00 2001 From: jingeonkim Date: Tue, 30 Jun 2026 14:19:10 +0900 Subject: [PATCH] refactor(ai-react): create useChat ChatClient via useState lazy init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useMemo` is a performance hint React may discard and recompute, which could spuriously construct a second ChatClient (each owns a StreamProcessor, a devtools bridge, and a connection). Switch to a `useState` lazy initializer — a per-mount "runs once" guarantee — recreating the client synchronously on `id` change via the adjust-state-during-render pattern. Also removes an unused `isFirstMountRef`. No public API or behavior change; the 152 useChat unit tests are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/usechat-lazy-client-instance.md | 5 ++++ packages/ai-react/src/use-chat.ts | 29 +++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 .changeset/usechat-lazy-client-instance.md diff --git a/.changeset/usechat-lazy-client-instance.md b/.changeset/usechat-lazy-client-instance.md new file mode 100644 index 000000000..a6bf9afb8 --- /dev/null +++ b/.changeset/usechat-lazy-client-instance.md @@ -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. diff --git a/packages/ai-react/src/use-chat.ts b/packages/ai-react/src/use-chat.ts index 37444b569..0a64dbbd5 100644 --- a/packages/ai-react/src/use-chat.ts +++ b/packages/ai-react/src/use-chat.ts @@ -51,23 +51,29 @@ export function useChat< const messagesRef = useRef>>( options.initialMessages || [], ) - const isFirstMountRef = useRef(true) const activeClientRef = useRef(null) const cleanupInvalidationRef = useRef | 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>(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 => { 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 @@ -159,7 +165,18 @@ export function useChat< }) activeClientRef.current = instance return instance - }, [clientId]) + } + + const [client, setClient] = + useState>(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()