(Part 8) Build a Simple Chat Character Gallery: Integrating Chat API


📚 Tutorial Series: Build a Chat Character Gallery
Integrating Chat API — You are here!
👋 Recap from Previous Post
Yesterday, we finished building:
✅ Chat Page UI with a header, conversation area, and input section.
✅ The page now displays mock chat history, shows typing indicators, and includes a functional input field with validation
Next step, API integration for the chat.
Integrating Chat API
🎯 Goals for This Post
In this part of the tutorial, we’ll integrate the Chat API with our ChatPage
. By the end, you’ll be able to use the chatbot gallery end-to-end. From creating a new chatbot to having a full conversation with it. We’ll use the same API from earlier, which means each chatbot’s experience and personality will reflect exactly what the user described when creating it.
📱 Step by Step Guide
- Create
ChatService.tsx
file
First, let’s create a new file ChatService.tsx
in /src/services/
. This file will store all the API request functions related to our chat feature.
import {
AllChatGetResponse,
ChatMessageGetResponse,
PostChatMessageRequest,
PostChatMessageResponse,
PostChatResponse,
} from '@/types/chat';
import HttpClient from './HttpClient';
interface ChatServiceType {
getAllChats: () => Promise<AllChatGetResponse>;
getChatMessages: (id: string) => Promise<ChatMessageGetResponse>;
postChat: (personaId: string) => Promise<PostChatResponse>;
postChatMessage: (
id: string,
payload: PostChatMessageRequest
) => Promise<PostChatMessageResponse>;
}
class ChatService implements ChatServiceType {
private _http: HttpClient;
constructor(httpClient: HttpClient) {
this._http = httpClient;
}
getAllChats: ChatServiceType['getAllChats'] = () => this._http.get('/chats');
getChatMessages: ChatServiceType['getChatMessages'] = (id) =>
this._http.get(`/chats/${id}/messages`);
postChat: ChatServiceType['postChat'] = (personaId) =>
this._http.post('/chats', { persona_id: personaId });
postChatMessage: ChatServiceType['postChatMessage'] = (id, payload) =>
this._http.post(`/chats/${id}/messages`, payload);
}
export default ChatService;
Inside this file, we have four API request functions for different endpoints:
getAllChats
– Retrieves all ongoing chats with the available chatbots.getChatMessages
– Fetches the chat history between a specific user and chatbot.postChat
– Creates a new chat between a user and a chatbot.postChatMessage
– Sends a new message to a chatbot.
Since we already created the schema and types for chats in the previous post, all the types used here already exist meaning no annoying import errors this time.
Before moving on, make sure to export ChatService
from /services/index.tsx
so it can be easily imported throughout the app.
import ChatService from "./ChatService";
import HttpClient from "./HttpClient";
import PersonaService from "./PersonaService";
const httpClient = new HttpClient();
export const personaService = new PersonaService(httpClient);
export const chatService = new ChatService(httpClient);
- Create a Hook for the Service
Next, we’ll create a hook to handle all the API requests from our service file using react-query
. Create a new file: /src/hooks/chat.ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AllChatGetResponse,
ChatMessageGetResponse,
PostChatMessageRequest,
PostChatMessageResponse,
} from "@/types/chat";
import toast from "react-hot-toast";
import { chatService } from "@/services";
export const useGetAllChats = () => {
return useQuery<AllChatGetResponse, Error>({
queryKey: ["get", "all", "chats"],
queryFn: async () => await chatService.getAllChats(),
});
};
export const useGetChatMessages = (chatId: string) => {
return useQuery<ChatMessageGetResponse, Error>({
queryKey: ["get", "chat", "messages", chatId],
queryFn: async () => await chatService.getChatMessages(chatId),
});
};
export const usePostChat = () => {
const queryClient = useQueryClient();
const { mutate, mutateAsync, isPending, isError, error } = useMutation({
mutationFn: (personaId: string) => chatService.postChat(personaId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["get", "all", "chats"] });
toast.success("Chat created successfully");
},
onError: (error: Error) => {
console.error("Error creating chat:", error.message);
toast.error(`Failed to create chat: ${error.message}`);
},
});
return { mutate, mutateAsync, isPending, isError, error };
};
export const usePostChatMessage = () => {
const queryClient = useQueryClient();
const { mutate, isPending, isError, error } = useMutation<
PostChatMessageResponse,
Error,
{ chatId: string; payload: PostChatMessageRequest },
Partial<{ prevData: ChatMessageGetResponse }>
>({
mutationFn: ({
chatId,
payload,
}: {
chatId: string;
payload: PostChatMessageRequest;
}) => chatService.postChatMessage(chatId, payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["get", "chat", "messages", variables.chatId],
});
toast.success("Message sent successfully");
},
onError: (error, { chatId }, context) => {
console.error("Error sending chat messages:", error.message);
toast.error(`Failed to send chat message: ${error.message}`);
if (context?.prevData) {
queryClient.setQueryData(
["get", "chat", "messages", chatId],
context.prevData
);
}
}, // After success or error, refetch data
onSettled: (_, _unused, { chatId }) => {
queryClient.invalidateQueries({
queryKey: ["get", "chat", "messages", chatId],
});
},
});
return { mutate, isPending, isError, error };
};
We’ve created four hooks, matching the four API functions from ChatService.tsx
:
useGetAllChats
anduseGetChatMessages
– Straightforward hooks that simply call their respective get functions and assign them query keys, so we can easily invalidate and refetch them later if needed.usePostChat
– Similar to the above but includesonSuccess
andonError
callbacks. On success, it invalidates the chat-related query key so the UI refreshes with the new data. On error, it displays feedback to the user via a toast notification.usePostChatMessage
– Works the same way asusePostChat
, but for messages. It invalidates the relevant query key to fetch the latest messages and shows a toast if an error occurs.
- Update
GalleryPage
Now, let’s add the hooks to GalleryPage
and ChatPage
. Why GalleryPage
? Because before a user can start chatting with a chatbot, we need to call usePostChat
to initiate the conversation. At the same time, we also need to check if there’s already an existing conversation between the user and that chatbot. If one exists, we’ll load it instead of creating a new one.
Update /gallery/index.tsx
with the following code:
const GalleryPage = () => {
// ... rest of the codes
// Fetch personas data
const {
data: personasResponse,
isLoading,
error,
refetch,
} = useGetAllPersona();
// ----- Add these codes -----
const { data: conversationData } = useGetAllChats();
const { mutateAsync } = usePostChat();
// ----- END -----
// ... rest of the codes
// ----- Add these codes ------
const onCardClick = async (id: string) => {
// Search for existing conversation
const existingConversation = conversationData?.conversations.filter(
(e) => e.persona_id === id
);
// If conversation exists, redirect to it
if (existingConversation && existingConversation.length > 0) {
router.push(`/chat/${id}/${existingConversation[0].id}`);
return;
}
// Else create a new conversation
const res = await mutateAsync(id);
router.push(`/chat/${id}/${res.conversation_id}`);
};
// ----- END -----
// ... rest of the codes
From the code above, we call useGetAllChats
and usePostChat
inside GalleryPage
, placing them right below useGetAllPersona
. These functions run when the user clicks Start Chat on a chatbot card. They check for any existing conversation, and if found, share the conversation ID so the user can continue where they left off. If not, they create a brand-new chat session with the selected chatbot.
Next, we’ll update ChatPage
to use the new hooks.
- Update
ChatPage
const ChatPage: React.FC = () => {
const { personaId, conversationId } = useParams();
const {
data: messagesData,
isLoading: isMessagesLoading,
error: messagesError,
} = useGetChatMessages(conversationId as string);
const [curInput, setCurInput] = useState<string>("");
const [messages, setMessages] = useState<string[]>([]);
const lastDataRef = useRef<Partial<ChatMessageGetResponse>>({});
useEffect(() => {
if (messagesData && messagesData !== lastDataRef.current) {
lastDataRef.current = messagesData;
setMessages([]);
}
}, [messagesData]);
const updatedMessages = useMemo(() => {
if (messages.length > 0) {
const newMsgs = {
id: "this-is-temporary-id",
conversation_id: "this-is-temporary-conversation-id",
role: "user",
message: messages,
updated_at: new Date(),
created_at: new Date(),
};
return messagesData ? messagesData?.data.concat(newMsgs) : [newMsgs];
}
return messagesData?.data || [];
}, [messagesData?.data, messages]);
return (
// ... rest of the code here
)
}
From the code above, we added useParams
to retrieve personaId
and conversationId
from the URL. These IDs are then used by useGetChatMessages
to fetch the conversation history.
We also introduced a new messages
state, which temporarily stores messages the user wants to send to the chatbot. These are then merged with messagesData
. Make sure to remove any duplicate variables, especially mock data that’s now replaced by our new state.
Additionally, we added a useEffect
hook to check if the data from useGetChatMessages
is in sync with messages
. If they don’t match, the messages state will be reset.
Next, we’ll handle sending messages to the chatbot using usePostChatMessage
:
const ChatPage: React.FC = () => {
// ... rest of the codes
const {
data: messagesData,
isLoading: isMessagesLoading,
error: messagesError,
} = useGetChatMessages(conversationId as string);
// ----- Add this line below -----
const { mutate, isPending } = usePostChatMessage();
const [curInput, setCurInput] = useState<string>('');
const [messages, setMessages] = useState<string[]>([]);
const lastDataRef = useRef<Partial<ChatMessageGetResponse>>({});
useEffect(() => {
if (messagesData && messagesData !== lastDataRef.current) {
lastDataRef.current = messagesData;
setMessages([]);
}
}, [messagesData]);
// ----- Add these -----
const onCallMutate = useCallback(
debounce((conversationId, messages) => {
if (messages.length > 0 && curInput === '') {
mutate({
chatId: conversationId as string,
payload: { query: messages },
});
}
}, 3000),
[]
);
// ----- END -----
// ----- Update these functions -----
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setCurInput(value);
onCallMutate(conversationId, messages);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newMsg = messages.concat(curInput);
setMessages(newMsg);
setCurInput('');
onCallMutate(conversationId, newMsg);
};
if (!data) return null;
// ----- END -----
// ... rest of the codes
}
Here, we added usePostChat
to submit messages from the user to the chatbot. The onCallMutate
function triggers the usePostChat
mutation, and we wrap it with a debounce
so the message is only sent once the user finishes typing or submits.
We’ll implement the debounce
function shortly.
We updated both onInputChange
and handleSubmit
so they call onCallMutate
when ready. In handleSubmit
, we also add the new message to messages so it appears instantly in the UI before the API call completes.
Before moving on, let’s create our debounce
function by editing src/lib/utils
:
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
// --- Add this ----
export function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}
The debounce
function ensures a given function is only executed after a specified delay without being triggered again.
Next, we’ll enhance the chat header with persona details:
const ChatPage: React.FC = () => {
const { personaId, conversationId } = useParams();
// ----- Add this line -----
const { data } = useGetPersona(personaId as string);
const {
data: messagesData,
isLoading: isMessagesLoading,
error: messagesError,
} = useGetChatMessages(conversationId as string);
const { mutate, isPending } = usePostChatMessage();
// ... rest of the code
// ----- Add these codes -----
const personalityArr = data?.data.personality
? data?.data.personality.split(",").map((item) => item.trim())
: [];
const interestsArr = data?.data.interests
? data?.data.interests.split(",").map((item) => item.trim())
: [];
// ----- END -----
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setCurInput(value);
onCallMutate(conversationId, messages);
};
// ... rest of the code
}
Here, we used useGetPersona
to fetch persona information. You may notice that useGetPersona
hasn’t been created yet, we’ll build it next.
We also introduced personalityArr
and interestsArr
to format persona details into arrays for display. Again, remember to remove duplicate variables, especially mock data.
In PersonaService.ts
, we added a new getPersona
function along with its type:
import {
Persona,
PersonaGetAllResponse,
PersonaGetResponse,
PersonaPostResponse,
} from "@/types/persona";
import HttpClient from "./HttpClient";
interface PersonaServiceType {
getAllPersona: () => Promise<PersonaGetAllResponse>;
getPersona: (id: string) => Promise<PersonaGetResponse>;
addPersona: (payload: Persona) => Promise<PersonaPostResponse>;
}
class PersonaService implements PersonaServiceType {
private _http: HttpClient;
constructor(httpClient: HttpClient) {
this._http = httpClient;
}
getPersona: PersonaServiceType["getPersona"] = (id) =>
this._http.get(`/persona/${id}`);
getAllPersona: PersonaServiceType["getAllPersona"] = () =>
this._http.get("/persona");
addPersona: PersonaServiceType["addPersona"] = (payload) =>
this._http.post("/persona", payload);
}
export default PersonaService;
This function retrieves detailed persona data based on the given ID.
Finally, we updated /hooks/persona.ts
:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Persona,
PersonaGetAllResponse,
PersonaGetResponse,
} from '@/types/persona';
import toast from 'react-hot-toast';
import { personaService } from '@/services';
export const useCreatePersona = () => {
const queryClient = useQueryClient();
const { mutate, isPending, isError, error } = useMutation({
mutationFn: (payload: Persona) => personaService.addPersona(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['get', 'persona'] });
toast.success('Persona created successfully');
},
onError: (error: Error) => {
console.error('Error creating persona:', error.message);
toast.error(`Failed to create persona: ${error.message}`);
},
});
return { mutate, isPending, isError, error };
};
export const useGetPersona = (personaId: string) => {
return useQuery<PersonaGetResponse, Error>({
queryKey: ['get', 'persona', personaId],
queryFn: async () => await personaService.getPersona(personaId),
});
};
export const useGetAllPersona = () => {
return useQuery<PersonaGetAllResponse, Error>({
queryKey: ['get', 'all', 'persona'],
queryFn: async () => await personaService.getAllPersona(),
});
};
Here, we added useGetPersona
, a simple react-query
wrapper for our API request. This will fix the import error for useGetPersona
in ChatPage
.
🎉 Congratulations! You’ve now completed the tutorial and built a fully functional chat interface from start to finish. If you’d like to explore the complete source code for this project, you can find it here:
This should give you a solid foundation for integrating and customizing the API to fit your own projects. If you still have questions or want to dive deeper into how the API works, feel free to reach out—I’m happy to help you explore it further. Now go ahead and start building something amazing! 🚀
Subscribe to my newsletter
Read articles from James Tedy directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
