NextJS Course (7/9) – Blockbuster Chat V2 – Implementing the Front-End

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! πŸš€

Leave a Comment