Skip to content

Conversation

@lqhuang
Copy link
Contributor

@lqhuang lqhuang commented Sep 9, 2025

I have tried AI SDK v5 + Jotai in my personal project to leverage new architecture. It's indeed much easier to use and maintain.

Current PR is still draft. @himself65 you could have a quick look for new implementation under v5 folder.

/**
 * Declare jotai atoms and jotai chat state
 */
import { JotaiChat, JotaiChatState } from 'jotai-ai/v5/chat'

// Custom yourself `ChatMsg` by extending `UIMessage`
export const messagesAtom = atom<ChatMsg[]>([])
export const statusAtom = atom<ChatStatus>('ready')
export const errorAtom = atom<Error | undefined>(undefined)

export const chatState = new JotaiChatState<ChatMsg>({
  messagesAtom,
  statusAtom,
  errorAtom,
})
export const jotaiChat = new JotaiChat<ChatMsg>({
  id: 'chat-id-xxxxx',
  transport: new DefaultChatTransport({
    api: CHAT_API_ENDPOINT,
  }),
  state: chatState,
  onError: error => {
    if (error) {
      toast({ type: 'error', description: error.message })
    }
  },
})
/**
 * Use `jotaiChat` in components
 */
import { useChat } from '@ai-sdk/react';
import { jotaiChat } from './store.ts'

export const ChatComponent = () => {

  const chatHelpers = useChat<ChatMsg>({
    chat: jotaiChat,
    experimental_throttle: 100,
  });

  const { messages, status, sendMessage, stop } = chatHelpers;

  return (<>
    ...
    ...
  <>)

}

And I hope some suggestions like do I need to remove all legacy make-chat-atoms? Or we could open a new branch to release newer package?

Extra actions


First update

After discussion with @songkeys, I have updated the design of API. Adding one atom constructor atomWithChat and one hook function useChatAtomValue. Basically, try to reuse AbstractChat and rewrite useChat to match the philosophy of Jotai itself.

First, declare the chatAtom

import { Chat } from '@ai-sdk/react';

const { chatAtom } = atomWithChat(get => {
  return new Chat<UIMessage>({
    // Put `useChat`'s `ChatInit` arguments here.
  });
});

then, we could use the chatAtom in the component like other atoms with customized hook useChatAtomValue

const Component = () => {
  const {
    messages,
    sendMessage,
    error,
    status,
    id, // the same return of `useChat` hook
  } = useChatAtomValue(chatAtom);

  ...

  return (<>
    ...
  </>)
}

Yes, the API is very similar to the jotai-tanstack-query, the limitation parts we will discuss later.

For the first input argument of the atomWithChat, it's just a reader function, so we could compose other atoms and state easily,

import { useHydrateAtoms } from 'jotai/utils';

const idAtom = atom<string>('initial-id');
const messagesAtom = atom<UIMessage[]>([])
const { chatAtom } = atomWithChat(get => {
  return new Chat({
    id: get(idAtom),
    messages: get(messagesAtom),
  });
});

const Component = ({ id: idParam }: { id: string }) => {
  useHydrateAtoms([[idAtom, idParam]]);

  const [idKey, setId] = useAtom(idAtom);
  const {
    messages,
    sendMessage,
    error,
    status,
    id,
  } = useChatAtomValue(chatAtom);

  if (idKey != id) throw new UnreachableError("they're definitely identical in value")

  return (<>
    ...
  </>)
}

Compatible with SSR and whatever other approaches to initial ID or messages by Jotai style atoms (like atomFamily).

That's all! No more dark magic, just Jotai.

Current results of UI tests (adapt useChat test cases from upstream) are added as follows:

$ pnpm run ui-test

> [email protected] ui-test /Users/lqhuang/Git/jotai-ai/packages/jotai-ai
> vitest --config vitest.ui.config.ts --reporter=verbose


 DEV  v3.2.4 /Users/lqhuang/Git/jotai-ai/packages/jotai-ai

stderr | src/hooks/use-chat-atom.ui.test.tsx > file attachments with data url > should handle text file attachment and submission
Not implemented: HTMLFormElement's requestSubmit() method

stderr | src/hooks/use-chat-atom.ui.test.tsx > file attachments with data url > should handle image file attachment and submission
Not implemented: HTMLFormElement's requestSubmit() method

stderr | src/hooks/use-chat-atom.ui.test.tsx > file attachments with url > should handle image file attachment and submission
Not implemented: HTMLFormElement's requestSubmit() method

stderr | src/hooks/use-chat-atom.ui.test.tsx > attachments with empty submit > should handle image file attachment and submission
Not implemented: HTMLFormElement's requestSubmit() method

 ✓ src/hooks/use-chat-atom.ui.test.tsx (29 tests) 2800ms
   ✓ initial messages (1)
     ✓ should show initial messages 17ms
   ✓ data protocol stream (8)
     ✓ should show streamed response 77ms
     ✓ should show user message immediately 17ms
     ✓ should show error response when there is a server error 15ms
     ✓ should show error response when there is a streaming error 14ms
     ✓ status (2)
       ✓ should show status 17ms
       ✓ should set status to error when there is a server error 11ms
     ✓ should invoke onFinish when the stream finishes 17ms
     ✓ id (1)
       ✓ send the id to the server 11ms
   ✓ text stream (3)
     ✓ should show streamed response 15ms
     ✓ should have stable message ids  318ms
     ✓ should invoke onFinish when the stream finishes 15ms
   ✓ prepareChatRequest (1)
     ✓ should show streamed response 13ms
   ? onToolCall (1) => (The whole test is masked temporarily)
     ? should invoke onToolCall when a tool call is received from the server's response
   ✓ tool invocations (3)
     ✓ should display partial tool call, tool call, and tool result  920ms
     ✓ should display tool call and tool result (when there is no tool call streaming) 19ms
     ✓ should update tool call to result when addToolResult is called  317ms
   ✓ file attachments with data url (2)
     ✓ should handle text file attachment and submission 86ms
     ✓ should handle image file attachment and submission 79ms
   ✓ file attachments with url (1)
     ✓ should handle image file attachment and submission 64ms
   ✓ attachments with empty submit (1)
     ✓ should handle image file attachment and submission 11ms
   ✓ should send message with attachments (1)
     ✓ should handle image file attachment and submission 10ms
   ✓ regenerate (1)
     ✓ should show streamed response 19ms
   ✓ test sending additional fields during message submission (1)
     ✓ should send metadata with the message 10ms
   ✓ resume ongoing stream and return assistant message (1)
     ✓ construct messages from resumed stream 7ms
   ✓ stop (1)
     ✓ should show stop response  327ms
   ✓ experimental_throttle (1)
     ✓ should throttle UI updates when experimental_throttle is set 13ms
   ✓ id changes (1)
     ✓ should update chat instance when the id changes 21ms
   ✓ chat instance changes (1)
     ✓ should update chat instance when the id changes 17ms
   ✓ streaming with id change from undefined to defined (1)
     ✓ should handle streaming correctly when id changes from undefined to defined  321ms

 Test Files  1 passed (1)
      Tests  29 passed (29)
   Start at  21:43:21
   Duration  3.42s (transform 71ms, setup 0ms, collect 228ms, tests 2.80s, environment 215ms, prepare 37ms)

Note

The stderr output "Not implemented: HTMLFormElement's requestSubmit() method" is not our responsibility.

I have passed almost all UI tests, except one corner case, which may fix later.

What extra misc I added in this PR:

  1. Add an action to run full-test for push and pull_request
  2. Split build script to build and build:examples to improve scope of building.

Known issues and limitations

  1. useChatAtomValue is forcedly required now.

Unlike jotai-tanstack-query, we cannot use native jotai hooks like useAtom/useAtomValue (useSetAtom is fine).

const { messages, status, sendMessage } = useAtomValue(chatAtom);
const { messages, status, sendMessage } = useChatAtomValue(chatAtom);

Unfortunately, ⚠️ The above two hooks have different behaviors now. The good news is the caused reason is clear and fixable. In general, we have no atomWithExternalSync or atomWithObservable, so we cannot subscribe change events from external sources inside atomWithChat. Hence, we have to implement the sync effect inside useChatAtomValue or useChatAtom, see the following example:

// Yeah, I try to expose extra atoms as small helpers.
const { 
  idAtom,
  errorAtom,
  statusAtom,
  messagesAtom,
  chatAtom,
} = atomWithChat(() => new Chat({}));

// But we can't just
const [status, setStatus] = useAtom(statusAtom)
// `status` won't change anymore if we execute `setStatus('...')`, since it never syncs to external store after creat.

// The current solution is implementing a `useStatusAtom` (pseudo, just for explaining)
import { useSyncExternalStore } from 'react';

export function useStatusAtom(chatAtom) {
  const chat = useAtomValue(chatAtom);
  const { setStatus } = useSetAtom(chatAtom);

  const status = useSyncExternalStore(
    chat['~registerStatusCallback'],
    () => chat.status,
    () => chat.status,
  );

  return [status, setStatus]
}

That's why they have different behaviors now. If we can move the sync effect logic into atomWithChat via atomWithExternalStorage, then we unify them in the future.

I also found some discussions to implement atomWithExternalStorage in 2022, but have no more progress since then.

  1. Synchronizing atoms with external sources pmndrs/jotai#1487
  2. Recoil Sync Atom Effect - syncEffect()

Current solution: We can try to add explicit warnings in docs to tell users to avoid mistakes.

  1. I don't implement setMessages (handler in useChat returns) yet.

As ai-sdk doc say:

setMessages: Function to update the messages state locally without triggering an API call. Useful for optimistic updates.

But actually, IMHO, it's quite unusable or unsafe operation. There is no more mechanism to deal with what if optimistic updates failed even use the official API. The user still need to implement all other details. So I think we should left this part to the user self, if they know what they're doing, setMessages is not difficult to implement with jotai-ai

Copy link
Member

@himself65 himself65 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is good. Thank u for doing the migration. It's okay to break the things for v5

@himself65
Copy link
Member

lmk if you have any updates

@lqhuang
Copy link
Contributor Author

lqhuang commented Sep 13, 2025

lmk if you have any updates

Ok. I see. Trying to focus on this asap 🥹

@songkeys
Copy link

is syncing upon the Chat class on the hooks level a better idea?

like this:

export function useJotaiChat({
	chat: Chat,
	...opts
}) {
	const [messages, setMessages] = useAtom(messageAtom);
	const [status, setStatus] = useAtom(statusAtom);
	const [error, setError] = useAtom(errorAtom);

	useEffect(() => {
		const unregisterMessagesCallback = chat[
			"~registerMessagesCallback"
		](() => {
			setMessages(chat.messages);
		}, 50);

		const unregisterStatusCallback = chat["~registerStatusCallback"](
			() => {
				setStatus(chat.status);
			},
		);

		const unregisterErrorCallback = chat["~registerErrorCallback"](
			() => {
				setError(chat.error);
			},
		);

		return () => {
			unregisterMessagesCallback();
			unregisterStatusCallback();
			unregisterErrorCallback();
		};
	}, [chat, setMessages, setStatus, setError]);

	const ret = useChat({
		chat,
		...opts
	});

	return {
		...ret,
		messages,
		status,
		error,
	};
}

@lqhuang
Copy link
Contributor Author

lqhuang commented Sep 24, 2025

Thank @songkeys for his innovated solutions. I tried it, and it actually works in some test cases, I'm also grad to learn something new from it! Unfortunately, that's not enough.

Then say hello to all subscribers, I'm here to sharing my progress recently.

My first try was structurally inspired (emmmm, almost copied) from FranciscoMoretti's Gist - Zustand + AI SDK useChat Integration. However, it just "looks great". When I added unit tests, my first version failed everywhere (for example, infinite re-render loops). Then I started to debug and dive into the internal details of useChat from @ai-sdk/react. And I also tried to optimize via useMemo or useCallback approach to avoid infinite loops, through they may not work at all.

The new useChat hooks in new v5 architecture is simpler than before, which only has some codes to let useSyncExternalStore and AbstractChat(w/ ChatTransport) work together.

I was thinking, the key of the solution is how to cooperate jotai and useSyncExternalStore (I'm not very sure does #11 also result in similar cases). Finally, during my research work, I found Dashi's great articles to solve my confuses! I strongly recommend who're interested in this PR to read When I Use Valtio and When I Use Jotai and optional Why useSyncExternalStore Is Not Used in Jotai .

I repost the most enlightening illumination here

tech-diff

TL;DR:

  1. zustand or valtio are naturally cooperated much better with useExternalSync since that's why they are created.
  2. A bridge is inevitably required between jotai and external state.

Hence, my original schedules would be:

  1. Introduce zustand/valtio as adapter middleware
    • Pros: there are very matured jotai-zustand/jotai-valtio packages.
    • Cons: It's a wired "Frankenstein" solution, and user probably have its own zustand dep version.
  2. Implement a simple jotai-swr (like jotai-tanstack-query) to bridge them.
    • Pros: Since ai package has also delivered swr package, it's a very friendly solution. What's more, we can improve and get an individual jotai-swr in the future.
    • Cons: May not the optimal solution. And need more implementations and tests to ensure things work well.
    • Extra bonus: In useChat hooks, swr is used in default chat transport implicitly, where the "implicit" means you could customize yourself transport without swr. But in useCompletion and useObject, these two hooks are more stateless compare to useChat, swr is a built-in and enforced dependency. If we have jotai-swr, it would be easy to migrate useCompletion and useObject to jotai-ai.
  3. Maintain internal jotai chat state with atomWithObserver
    • Pros: Most "jotai native" and primitive solution. Probably will minimize extra re-renders while cooperating.
    • Cons: Browser native Observable still in TC39 Stage 2 or 3? Only Chrome after 135 version implement it as I know now. End developers will be required to add a polyfill package, or else introduce rxjs.

PS: I'm now trying to play some magical/dark tricks via jotai store with subscription methods as attempts.

Now, let me clarify why @songkeys' approach is not enough:

  1. The approach is very similar to my previous PR to fix v4 adaptions (test: Add companion ui tests for useChat from upstream #16) which may cause one extra re-render. (Or maybe not, honestly, I'm not sure.)
  2. That's not "jotai style". What's the "jotai style"? My understanding and explanation here is decomposing state management and react component in a most optimal approach. There are two points:
    • useJotaiChat wouldn't use again in child component, you need to pass the handler to children as props.
    • Both useAtom and useJotaiChat will expose setMessages handler, and they have different behaviors. That's not a safe pattern in API design.

But I indeed get inspired from @songkeys' suggestions. Especially, I have new ideas and new directions when I'm writing and organizing this reply (aha, that's how discussions work 🤣).

My eventual desired goals of jotai-ai:

  1. Collaborate with other atoms natively
    • We could also represent it to work seamlessly with other packages in Jotai's ecosystem.
  2. Reduce re-renders as more as possible.
  3. Follow upstream changes with minimal efforts.

So the API will be like:

const initialMessagesAtom = atom(...);
const initialChatId = atom(...);

// Just to show the most jotai primitive function call
// const { messagesAtom, statusAtom, errorAtom } = atomAIChat(
//   get => {}, 
//   (get, set, ...) => {},
// );
const { messagesAtom, statusAtom, errorAtom } = atomWithAIChat({
  initialMessagesAtom
});
// So what the `jotai-ai` really do is to help end developers to write getter and setter.
// Keep the dark detail inside of `atomWithAIChat`
// and let end users to compose or derive atoms as they hope!.

const ChatComponent = () => {
  const [messages, setMessages] = useAtom(messagesAtom)
  // Or actually we could customize a hook to expose more handlers
  const {messages, sendMessages, resendMessages, abortSendingMessages} = useChatMessagesAtom(messagesAtom)


  return (<>
    ...
    ...
  <>)
}

IMHO, that's the "Jotai style", that's also my motivation of PR #15 which I look like to forget yet.

What I have mistaken is I hope to reuse useChat and implement AbstractChat into JotaiChat, but @songkeys let me aware that I should reuse DefaultChatTransport, and reimplement both useChat and AbstractChat. What's more, the brilliant point is the AbstractChat could be also featured as an adapter to let user bridge other state management into jotai-ai, which means user can choose to pass initialMessageAtoms into atomWithAIChat or pass ZustandChat into useJotaiChat.

So next step, I plan to try some new API signatures to improve overall usabilities.

Any further discussion and suggestion is appreciated and welcome! See what amazing we have gained now!

Thanks for all!

@songkeys
Copy link

Thank you for sharing your thoughts! I learned a lot from your insights. Your new design truly embodies the jotai approach.

Here's a bit more about my current usage in my project, as I mentioned earlier:

Most AI chat applications today support multiple chats, and mine is no exception. I define my chat atoms like this:

export const chatMessagesFamily = atomFamily((chatId: string) => atom<Chat["messages"]>([]));
export const chatStatusFamily = atomFamily((chatId: string) => atom<Chat["status"]>("ready"));
export const chatErrorFamily = atomFamily((chatId: string) => atom<Chat["error"]>(undefined));

Next, I create a global chat instance map to ensure chat singletons:

const globalChatInstance: Record<string, Chat> = {};
const getChatInstance = (chatId: string) => {
	if (globalChatInstance[chatId]) {
		return globalChatInstance[chatId];
	}

	const chatInstance = new Chat({
		id: chatId,
		onError: (error) => {
			// We can manipulate the `messages` in the current chatInstance directly like this
			chatInstance.messages = chatInstance.messages.slice(0, -1);
			// it will sync with jotai atoms
		},
		onData: (part) => {
			// We can also set the jotai store
			// For example, here we update the current chat's title (which is stored in another atom)
			if (part.type === "data-title") {
				const nextTitle = part.data.text;
				store.set(chatConversationsAtom, (prev) => {
					return prev.map((c) =>
						c.id === chatId ? { ...c, title: nextTitle } : c,
					);
				});
			}
		},
	});

	globalChatInstance[chatId] = chatInstance;

	return chatInstance;
};

Then, the useJotaiChat hook is essentially the same as above but with just chatId passed in to ensure singletons and useAtom with atomFamily:

export function useJotaiChat({
	chatId: string
}) {
	const chatInstance = getChatInstance(chatId);

	const [messages, setMessages] = useAtom(chatMessagesFamily(chatId));
	const [status, setStatus] = useAtom(chatStatusFamily(chatId));
	const [error, setError] = useAtom(chatErrorFamily(chatId));

	useEffect(() => {
		const unregisterMessagesCallback = chat[
			"~registerMessagesCallback"
		](() => {
			setMessages(chat.messages);
		}, 50);

		const unregisterStatusCallback = chat["~registerStatusCallback"](
			() => {
				setStatus(chat.status);
			},
		);

		const unregisterErrorCallback = chat["~registerErrorCallback"](
			() => {
				setError(chat.error);
			},
		);

		return () => {
			unregisterMessagesCallback();
			unregisterStatusCallback();
			unregisterErrorCallback();
		};
	}, [chat, setMessages, setStatus, setError]);

	const ret = useChat({
		chat,
		...opts
	});

	return {
		...ret,
		messages,
		status,
		error,
	};
}

You mentioned:

... which may cause one extra re-render.

Yes. It will do.

useJotaiChat wouldn't use again in child component, you need to pass the handler to children as props.

Due to the global singletons, I actually use it directly in my child components. It might not be the most efficient way, but it feels natural to me so far. The only downside is that useEffect will run multiple times, leading to duplicated sync subscriptions, but I believe it won't trigger a re-render since they are the same object and will be deduplicated in React?

Of course, I can also use useAtom(chatMessagesFamily(chatId)) to retrieve only messages in child components. However, I need to be cautious about this:

Both useAtom and useJotaiChat will expose setMessages handler, and they have different behaviors. That's not a safe pattern in API design.

Yes. You're right. I need to only use useAtomValue when using my jotai chat states. Using setAtom won't sync back to the actual chat states in the AI SDK's instances.

I have to admit that my implementation isn't quite satisfying. I haven't thought deeply about it; I've just been trying to get my project completed... And when I realized I should have searched for something like jotai-ai, that's why I was here. 😄


Back to your new design, I believe we're on the right track. atomWithSomething that returns atoms feels so natural in jotai. However, could the function signatures be more like what jotai-tanstack-query did?:

const chatIdAtom = atom("random-id");
const chatAtom = atomWithChat((get) => ({
  id: get(chatIdAtom)
}));
const [{ messages, sendMessage, stop }] = useAtom(chatAtom);

@lqhuang
Copy link
Contributor Author

lqhuang commented Sep 25, 2025

@songkeys Thanks for sharing your use cases and feedbacks! That's great and also what we want.

However, could the function signatures be more like what jotai-tanstack-query did?

Sure, let's try together and do experiments to find the best approaches.

@lqhuang
Copy link
Contributor Author

lqhuang commented Sep 28, 2025

@songkeys I have updated API design. Please check, does the new API fit your use cases :)

@himself65 It seems I have done the major design, you could do basic review now? I will keep polishing the rest parts, e.g.: tune API options, fix corner cases and add new docs.

(It's very embarrassed that all tests passed in my local env, but become flaky in CI. Sometime successes, sometime fails. I haven't figured out why)

Please see the first update section in the main description.

@lqhuang lqhuang marked this pull request as ready for review September 28, 2025 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

infinity loading

3 participants