Welcome back to part 7! Let’s continue straight where we left off and continue on our blockbuster chat V2. Make sure you have your blockbuster_chat_2/page.tsx
file open where we just finished defining our ChatBubble
component in the last part:
π app π api π blockbuster π route.tsx π blockbuster_v2 π route.tsx π blockbuster_chat π page.tsx π blockbuster_chat_2 π page.tsx π οΈ We'll be working on this file π counter π page.tsx π types π chatMessage.ts π favicon.ico π globals.css π layout.tsx π navbar.tsx π page.tsx
Getting started on the main component
Let’s write the main BlockbusterChat
component in here. This one is going to be pretty large so I’ll necessarily have to break it down into smaller parts with comments in between. This can be a bit confusing indentation-wise so I’ll try to break it up in logical spots and after we are done I’ll show you the completed component once more for clarity.
If at any point the nesting and inner functions get confusing to follow along with check out the video tutorial instead, as it will be easier to follow along and explain visually while we go through this code block. So let’s get to it:
const BlockbusterChat: React.FC = () => { const [movieName, setMovieName] = useState<string>(""); const [movieCharacter, setMovieCharacter] = useState<string>(""); const [question, setQuestion] = useState<string>(""); const [isSubmitted, setIsSubmitted] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false); const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
This is mostly the same we’ve done before, except the answer
state we used last time is now gone and replaced by a new chatHistory
state which will be an array []
of ChatMessage
objects. Remember this is just the type that we defined ourselves in the types/chatMessage.ts
file in the previous part of the tutorial.
Using the useRef
hook
Now we’ll write some code to solve a problem that we will encounter. When the chat history gets very long, React will simply add chat bubbles to our page but they will be out of view with a scroll bar appearing as they simply do not fit on the page anymore. The user will have to manually scroll down when the content gets too large for the page. We want the page to scroll down automatically when new messages appear, just like a real chat app, so we’ll have to write some code to make that happen:
const chatHistoryRef = useRef<HTMLDivElement>(null); useEffect(() => { if (chatHistoryRef.current) { chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight; } }, [chatHistory]);
Now this part requires some explanation, but no worries! This is the useEffect
hook we learned about in an earlier part. Remember that the code inside the useEffect
hook ran once on the first render of the page? This time the dependency
array all the way at the tail end of the code contains [chatHistory]
. This means that the code inside this useEffect
hook will run every time the page’s chatHistory
gets updated.
First we use the useRef
hook to create a reference to the chatHistory
div element. So what is useRef
? It allows us to create a reference to an HTML element on the page. The constant chatHistoryRef
is a reference to the useRef
hook which takes an HTMLDivElement
as an argument, but as you can see we default it to null
. Later on in the code, when we create the HTML <div>
element that will hold the chat history, we will assign a ref
to the div
so that our code above will know that this is the HTML element we want to target.
If this seems confusing, know that chatHistoryRef, just refers to a div element on our HTML page. Later when we create the div element on our actual page, we will give it a code
that matches the chatHistoryRef
so that the two will link up together so to speak.
Now the code inside the useEffect
hook, which will run every time the chatHistory
is updated, will check if the chatHistoryRef.current
is not null
. This just means that the chatHistoryRef
is pointing to an actual HTML element on the page.
If it is not null
, we set the scrollTop
property of the chatHistoryRef.current
to the scrollHeight
of the chatHistoryRef.current
. This is a bit of a mouthful, but it just sets the current scroll location to the furthest scroll location possible for the chatHistoryRef, which again, we will hook to the specific div element on the page later on, so that this code will apply to that div element that will display the chat bubbles.
Writing the handleSubmit
function
Next up we’ll have our handleSubmit
function which is going to be reasonably long and is entirely nested within the BlockbusterChat
component:
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setIsSubmitted(true); setIsLoading(true);
Same as before, we have an async function that takes the FormEvent as input and prevents the default (page refreshing) behavior and we set our isSubmitted
and isLoading
states to true
.
const questionChatMessage: ChatMessage = { text: question, sender: "You", time_sent: new Date(), };
Whenever our form is submitted we create a new ChatMessage
object with the question
(which is held in React State) as the text
. The sender will be You
(which we decided in the previous part), and we just set the time_sent
to the current date and time.
setChatHistory((prevChatHistory) => [ ...prevChatHistory, questionChatMessage, ]);
We then need to update the ChatHistory
state using the setChatHistory
function. This one is a bit different as we need to add to the ChatHistory
built up so far, so the function takes (prevChatHistory)
as input and then returns (=>
) a new array that uses the ...
spread operator we learned about last time to just put all the previous items that were in the history array into the new one. We then add the questionChatMessage
to the end of the array so it is updated with the new question.
Make sure you never use chatHistory.push(questionChatMessage)
as this will not work as expected. React will not detect the change in the array and will not re-render the page. Always use the setChatHistory
function to update the state and use the ...
spread operator to add in the old values.
const response = await fetch("/api/blockbuster_v2", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ movieName, movieCharacter, question, chatHistory, }), });
Now we make the call to our new blockbuster_v2
API route. We use the fetch
function to make a POST
request to the /api/blockbuster_v2
route. This is the same as the other calls we have made to our own APIs before, except this time we add the chatHistory
into the body
just like we promised to send when we wrote the backend API.
const data: { answer: string } = await response.json();
We get the data which is a JSON object with a single key answer
which is a string (this is just the structure we decided to send back from our API). We parse it and store it in the constant data
.
const answerChatMessage: ChatMessage = { text: data.answer, sender: movieCharacter, isAnswer: true, time_sent: new Date(), };
We then create a new ChatMessage
object to represent this answer chat message. We set the text
to the answer
we got back from the API, the sender
to the movieCharacter
(which we have stored in state), and we set isAnswer
to true
to differentiate it from the question chat messages. We once again set the time_sent
to the current date and time.
setChatHistory((prevChatHistory) => [ ...prevChatHistory, answerChatMessage, ]); setQuestion(""); setIsLoading(false); };
We update the chatHistory
state with the new answerChatMessage
using the setChatHistory
function just like before, making sure to use the ...
spread operator to spread in the old values. We then reset the question
state to an empty string as we want the user to be able to ask another question, and set the isLoading
state to false
.
That completes the handleSubmit
function. Just for clarity, we are still inside the BlockbusterChat
component:
const BlockbusterChat: React.FC = () => { const chatHistoryRef = ... useEffect(() => {...} const handleSubmit = ... // We will be continuing here }
Writing the handleInputChange
function
Now we need to create the function that will handle the change of the input fields. This is the same as the one we had last time:
const handleInputChange = (setter: React.Dispatch<React.SetStateAction<string>>) => (event: ChangeEvent<HTMLInputElement>) => setter(event.target.value);
As this was explained in more detail over the previous parts, we won’t go over this one again but it just makes sure the state stays up to date with what the user types in the input boxes.
Returning the page content
Now it’s time to get to the return part of the BlockbusterChat
component:
const BlockbusterChat: React.FC = () => { const chatHistoryRef = ... useEffect(() => {...} const handleSubmit = ... const handleInputChange = ... return( // We will writing the return part next ) }
This is where we will write the JSX that will be rendered to the page. Again, I’ll show this part with comments in between to explain each part:
return ( <div className="max-w-3xl mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1> {/* Initial input form for character and first question */} {!isSubmitted && ( <form onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Movie Character" value={movieCharacter} onChange={handleInputChange(setMovieCharacter)} disabled={isSubmitted} /> <InputField label="Movie Name" value={movieName} onChange={handleInputChange(setMovieName)} disabled={isSubmitted} /> </div> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </form> )}
This form is the same as the form we had before. The only real changes I made were to set the className of the outer div to 3xl
instead of 2xl
for the max-w (maximum width) and switch the order of the movie character and movie name, as it seemed more logical to have the character first. I also changed the color styling on the submit button to use our custom-grey
color again.
I added a comment {/* Comment here */}
, note that {/* */}
is merely the syntax for comments in JSX. The last change is that the form has now been wrapped inside a conditional {!isSubmitted && (<form>... </form>)}
. As we learned before, this syntax will only show the form if isSubmitted
is false
. After the chat starts we want this form to disappear from view completely. We’ll have another input box later down for follow-up questions.
The next item we need inside our return
statement to display on the page is of course the chat history:
{/* Display chat history */} {chatHistory && ( <div ref={chatHistoryRef} className="mt-6 max-h-[70vh] overflow-y-scroll"> {chatHistory.map((chatMessage: ChatMessage, index) => ( <ChatBubble key={index} text={chatMessage.text} isAnswer={chatMessage.isAnswer} sender={chatMessage.sender} time_sent={chatMessage.time_sent} /> ))} </div> )}
We have a comment for clarity and then again conditionally render this part if a chatHistory exists {chatHistory && (<div>...</div>)}
. Note that the outer div for the chat history has a ref
attribute which is set to chatHistoryRef
. This is the reference we created earlier with the useRef
hook. The code inside useEffect
is going to see this ref
and see “Oh, this is the div I need to scroll down when new messages appear” and will do so automatically.
The classNames applied to the div are mt-6
for margin-top of 6 units and max-h-[70vh]
to set the chat history div to a maximum height of 70% of the viewport height. There are more precise ways to do this but let’s keep it simple for now. The overflow-y-scroll
class is to make sure a scroll bar appears when the chat history gets too long for the div.
Now we need to display the ChatBubble
components for each chat message in the chatHistory
array. We can use our trusty .map()
function again to loop over each ChatMessage
object in the chatHistory
array. Note that we can also add an index
as the second argument ((chatMessage: ChatMessage, index)
) to the map
function which will simply be the index of the current item in the array (0-> 1-> 2-> etc.). Components in React need a key
prop to uniquely identify them, and we know this index will always be different, so we can just use this as the key
value for each chat bubble created.
We pass in the text
, isAnswer
, sender
, and time_sent
properties to each ChatBubble
component created. The logic inside our ChatBubble
component will take care of the rest!
Next up in our return statement is a simple loading message:
{/* Loading message */} {isLoading && ( <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div> )}
If you want one of those cool spinners or bouncy ball things instead then check out the daisyUI docs after we are done. It’s really easy to do! For now I’ll just use a simple text message that displays whenever isLoading
is true
.
Finally, we need to have a short form for the user to be able to ask follow-up questions right at the bottom (like a real chat interface). This will be the last item in our return statement so we’ll also close the return and the BlockbusterChat
component:
{/* Display form for follow up questions */} {isSubmitted && !isLoading && ( <form className="mt-3" onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 h-10 mt-8 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </div> </form> )} </div> ); }; export default BlockbusterChat;
If isSubmitted
is true
and not (!
) isLoading
we show the form for follow-up questions. This form is the same as the initial form we had before, except it only has the question input box and the submit button. The submit button is styled a bit differently and next to the question input field in this case. We can just reuse the setQuestion
setter function to our handleInputChange
function to keep the question
state up to date as there is no need to create extra state for this. We then close the form, div, return statement, and the BlockbusterChat
component. Finally, we have the export default BlockbusterChat;
line as this is the main component on the page.
Time for some testing!
Phew! That was a lot of code! Here is the complete BlockbusterChat
component once more for clarity:
const BlockbusterChat: React.FC = () => { const [movieName, setMovieName] = useState<string>(""); const [movieCharacter, setMovieCharacter] = useState<string>(""); const [question, setQuestion] = useState<string>(""); const [isSubmitted, setIsSubmitted] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false); const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]); const chatHistoryRef = useRef<HTMLDivElement>(null); useEffect(() => { if (chatHistoryRef.current) { chatHistoryRef.current.scrollTop = chatHistoryRef.current.scrollHeight; } }, [chatHistory]); const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setIsSubmitted(true); setIsLoading(true); const questionChatMessage: ChatMessage = { text: question, sender: "You", time_sent: new Date(), }; setChatHistory((prevChatHistory) => [ ...prevChatHistory, questionChatMessage, ]); const response = await fetch("/api/blockbuster_v2", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ movieName, movieCharacter, question, chatHistory, }), }); const data: { answer: string } = await response.json(); const answerChatMessage: ChatMessage = { text: data.answer, sender: movieCharacter, isAnswer: true, time_sent: new Date(), }; setChatHistory((prevChatHistory) => [ ...prevChatHistory, answerChatMessage, ]); setQuestion(""); setIsLoading(false); }; const handleInputChange = (setter: React.Dispatch<React.SetStateAction<string>>) => (event: ChangeEvent<HTMLInputElement>) => setter(event.target.value); return ( <div className="max-w-3xl mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">Blockbuster Chat</h1> {/* Initial input form for character and first question */} {!isSubmitted && ( <form onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Movie Character" value={movieCharacter} onChange={handleInputChange(setMovieCharacter)} disabled={isSubmitted} /> <InputField label="Movie Name" value={movieName} onChange={handleInputChange(setMovieName)} disabled={isSubmitted} /> </div> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </form> )} {/* Display chat history */} {chatHistory && ( <div ref={chatHistoryRef} className="mt-6 max-h-[70vh] overflow-y-scroll"> {chatHistory.map((chatMessage: ChatMessage, index) => ( <ChatBubble key={index} text={chatMessage.text} isAnswer={chatMessage.isAnswer} sender={chatMessage.sender} time_sent={chatMessage.time_sent} /> ))} </div> )} {/* Loading message */} {isLoading && ( <div className="mt-6 text-gray-700">Asking {movieCharacter}...</div> )} {/* Display form for follow up questions */} {isSubmitted && !isLoading && ( <form className="mt-3" onSubmit={handleSubmit}> <div className="flex space-x-4"> <InputField label="Question" value={question} onChange={handleInputChange(setQuestion)} /> <button type="submit" className="px-4 py-2 h-10 mt-8 bg-custom-grey text-white rounded hover:bg-blue-900" disabled={isLoading} > Ask </button> </div> </form> )} </div> ); }; export default BlockbusterChat;
That’s it! Time to give this a test run and see if it works. Make sure your dev server is running (npm run dev
) and open your browser to http://localhost:3000/blockbuster_chat_2
:
Go ahead, don’t be shy! Notice the first form disappears after we start the conversation and changes to a more appropriate form:
Now see if you can ask a follow-up question:
And there you have it, the conversation is flowing quite naturally and I actually feel like I’m talking to the silly and awkward creature from Star Wars! Now whoever you are talking to, check to see if their ‘memory’ is working correctly just to be sure you’re having a real conversation:
And it passed the memory test with flying colors!
You may also see the scrollbar starting to appear if your browser window is not that tall. You will see our auto scroll-down feature kick in and provide a good user experience.
Pretty awesome and everything works as expected. You have your own fully fledged full-stack website with a working AI chat application. There is one more thing I want to do before we wrap this up though. I think we can make this even better and more immersive by adding a chat avatar/icon showing the character the user is chatting with.
So how do we do that when the user could literally type any character from any movie ever made? Well, we simply use AI to generate the required image for us on demand! I’ll see you soon in the next part, where we will take this chat app to the next level yet again! π