DEV Community

Cover image for Tutorial - Build a chatbot with React and OpenAI
Hassan Djirdeh
Hassan Djirdeh

Posted on

Tutorial - Build a chatbot with React and OpenAI

This article is the second article and tutorial sent on the frontendfresh.com newsletter. Subscribe to the Front-end Fresh newsletter to get front-end engineering tips, tutorials, and projects sent to your inbox on a weekly basis!

Last week, we built a Node.js/Express server that exposes an /ask endpoint. When this endpoint is triggered and we include a text prompt, the endpoint interacts with OpenAI's /completions API to generate and return a continuation of that text.

When we tested this with an example prompt like "How is the weather in Dubai?", the API returned a valid answer to us.

image.png

Today, we're going to build a User Interface (i.e. UI) that resembles a chatbot where the user can type a question and receive an answer from the Node.js backend API we created.

Scaffolding a React app

We'll be building the UI of our app with the React JavaScript library. To get started, we'll first want to scaffold a React development environment quickly and we'll do this with the help of Vite.

I have plans on writing an email that does a bit more of a deep-dive into Vite but in summary, Vite is a build tool and development server that is designed to optimize the development experience of modern web applications. Think Webpack but with faster build/start times and a few additional improvements.

To get started in scaffolding our React app, we'll follow the Getting Started documentation section of Vite, and we'll run the following in our terminal.

npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

We'll then be given a few prompts to fill. We'll state that we'll want our project to be named custom_chat_gpt_frontend and we'll want it to be a React/JavaScript app.

$ npm create vite@latest
✔ Project name: custom_chat_gpt_frontend
✔ Select a framework: › React
✔ Select a variant: › JavaScript
Enter fullscreen mode Exit fullscreen mode

We can then navigate into the project directory and run the following to install the project dependencies.

npm install
Enter fullscreen mode Exit fullscreen mode

When the project dependencies have finished installing, we'll run our front-end server with:

npm run dev
Enter fullscreen mode Exit fullscreen mode

We'll then be presented with the running scaffolded application at http://localhost:5173/.

image.png

Creating the markup & styles

We'll begin our work by first focusing on building the markup (i.e. HTML/JSX) and styles (i.e. CSS) of our app.

In the scaffolded React application, we'll notice a bunch of files and directories have been created for us. We'll be working entirely within the src/ directory. To get things started, we'll modify the autogenerated code in our src/App.jsx component to simply return "Hello world!".

import "./App.css";

function App() {
  return <h2>Hello world!</h2>;
}

export default App;
Enter fullscreen mode Exit fullscreen mode

We'll remove the scaffolded CSS styles in our src/index.css file and only have the following.

html,
body,
#root {
  height: 100%;
  font-size: 14px;
  font-family: arial, sans-serif;
  margin: 0;
}
Enter fullscreen mode Exit fullscreen mode

And in the src/App.css file, we'll remove all the initially provided CSS classes.

/* App.css CSS styles to go here */
/* ... */
Enter fullscreen mode Exit fullscreen mode

Saving our changes, we'll be presented with a "Hello world!" message.

image.png

We won't spend a lot of time in this email breaking down how our UI is styled. To summarize quickly, our final app will only contain a single input field section that both captures what the user types and the returned answer from the API.

image.png

We'll style the UI of our app with standard CSS. We'll paste the following CSS into our src/App.css file which will contain all the CSS we'll need.

.app {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.1);
}

.app-container {
  width: 1000px;
  max-width: 100%;
  padding: 0 20px;
  text-align: center;
}

.spotlight__wrapper {
  border-radius: 12px;
  border: 1px solid #dfe1e5;
  margin: auto;
  max-width: 600px;
  background-color: #fff;
}

.spotlight__wrapper:hover,
.spotlight__wrapper:focus {
  background-color: #fff;
  box-shadow: 0 1px 6px rgb(32 33 36 / 28%);
  border-color: rgba(223, 225, 229, 0);
}

.spotlight__input {
  display: block;
  height: 56px;
  width: 80%;
  border: 0;
  border-radius: 12px;
  outline: none;
  font-size: 1.2rem;
  color: #000;
  background-position: left 17px center;
  background-repeat: no-repeat;
  background-color: #fff;
  background-size: 3.5%;
  padding-left: 60px;
}

.spotlight__input::placeholder {
  line-height: 1.5em;
}

.spotlight__answer {
  min-height: 115px;
  line-height: 1.5em;
  letter-spacing: 0.1px;
  padding: 10px 30px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.spotlight__answer p::after {
  content: "";
  width: 2px;
  height: 14px;
  position: relative;
  top: 2px;
  left: 2px;
  background: black;
  display: inline-block;
  animation: cursor-blink 1s steps(2) infinite;
}

@keyframes cursor-blink {
  0% {
    opacity: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

We'll now move towards establishing the markup/JSX of our <App /> component. In the src/App.jsx file, we'll update the component to first return a few wrapper <div /> elements.

import "./App.css";

function App() {
  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          /* ... */
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Within our wrapper elements, we'll place an <input /> element and a <div /> element to represent the input section and the answer section respectively.

import "./App.css";
import lens from "./assets/lens.png";

function App() {
  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            type="text"
            className="spotlight__input"
            placeholder="Ask me anything..."
            style={{
              backgroundImage: `url(${lens})`,
            }}
          />
          <div className="spotlight__answer">
            Dubai is a desert city and has a warm and sunny climate throughout
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

For the <input /> element, we're adding an inline backgroundImage style property where the value is the .png image of a magnifying glass that we've saved in our src/assets/ directory. You can find a copy of this image here.

With our changes saved, we'll now be presented with the UI of the app the way we expected it to look.

image.png

Capturing the prompt value

Our next step is to capture the prompt value the user is typing. This needs to be done since we intend to send this value to the API when the input has been submitted. We'll capture the user input value in a state property labeled prompt and we'll initialize it with undefined.

import { useState } from "react";
import "./App.css";
import lens from "./assets/lens.png";

function App() {
  const [prompt, updatePrompt] = useState(undefined);

  return (
    /* ... */
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

When the user types into the <input /> element, we'll update the state prompt value by using the onChange() event handler.

import { useState } from "react";
import "./App.css";
import lens from "./assets/lens.png";

function App() {
  const [prompt, updatePrompt] = useState(undefined);

  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            // ...
            onChange={(e) => updatePrompt(e.target.value)}
          />
          // ...
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

We want the input to be "submitted" at the moment the user presses the "Enter" key. To do this, we'll use the onKeyDown() event handler and have it trigger a sendPrompt() function we'll create.

In the sendPrompt() function, we'll return early if the user enters a key that is not the "Enter" key. Otherwise, we'll console.log() the prompt state value.

import { useState } from "react";
import "./App.css";
import lens from "./assets/lens.png";

function App() {
  const [prompt, updatePrompt] = useState(undefined);

  const sendPrompt = async (event) => {
    if (event.key !== "Enter") {
      return;
    }

    console.log('prompt', prompt)
  }

  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            // ...
            onChange={(e) => updatePrompt(e.target.value)}
            onKeyDown={(e) => sendPrompt(e)}
          />
          // ...
        </div>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Now, if we type something into the input and press the "Enter" key, we'll be presented with that input value in our console.

image.png

Triggering the API

The final step to our implementation is triggering the API when the user presses the "Enter" key after typing a prompt in the input.

We'll want to capture two other state properties that will reflect the information of our API request — the loading state of our request and the answer returned from a successful request. We'll initialize loading with false and answer with undefined.

import { useState } from "react";
import "./App.css";
import lens from "./assets/lens.png";

function App() {
  const [prompt, updatePrompt] = useState(undefined);
  const [loading, setLoading] = useState(false);
  const [answer, setAnswer] = useState(undefined);

  const sendPrompt = async (event) => {
    // ...
  }

  return (
    // ...
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

In our sendPrompt() function, we'll use a try/catch statement to handle errors that may occur from the asynchronous request to our API.

const sendPrompt = async (event) => {
  if (event.key !== "Enter") {
    return;
  }

  try {

  } catch (err) {

  }
}
Enter fullscreen mode Exit fullscreen mode

At the beginning of the try block, we'll set the state loading property to true. We'll then prepare our request options and then use the native browser fetch() method to trigger our request. We'll make our request hit an endpoint labeled api/ask (we'll explain why in a second).

const sendPrompt = async (event) => {
  if (event.key !== "Enter") {
    return;
  }

  try {
    setLoading(true);

    const requestOptions = {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
    };

    const res = await fetch("/api/ask", requestOptions);
  } catch (err) {

  }
}
Enter fullscreen mode Exit fullscreen mode

If the response is not successful, we'll throw an error (and console.log() it). Otherwise, we'll capture the response value and update our answer state property with it.

This makes our sendPrompt() function in its complete state look like the following:

const sendPrompt = async (event) => {
  if (event.key !== "Enter") {
    return;
  }

  try {
    setLoading(true);

    const requestOptions = {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ prompt }),
    };

    const res = await fetch("/api/ask", requestOptions);

    if (!res.ok) {
      throw new Error("Something went wrong");
    }

    const { message } = await res.json();
    setAnswer(message);
  } catch (err) {
    console.error(err, "err");
  } finally {
    setLoading(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

Before we move towards testing that our request works as expected, we'll add a few more changes to our component.

When our loading state property is true, we'll want the input to be disabled and we'll also want to display a spinning indicator in place of the magnifying lens image (to convey to the user that the request is "loading").

We'll display a spinning indicator by conditionally dictating the value of the backgroundImage style of the <input /> element based on the status of the loading value. We'll use this spinner GIF that we'll save int our src/assets/ directory.

import { useState } from "react";
import "./App.css";
import loadingGif from "./assets/loading.gif";
import lens from "./assets/lens.png";

function App() {
  // ...

  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          <input
            // ...
            disabled={loading}
            style={{
              backgroundImage: loading ? `url(${loadingGif})` : `url(${lens})`,
            }}
            // ...
          />
          // ...
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the answer section of our markup, we'll conditionally add a paragraph tag that contains the {answer} value if it is defined.

import { useState } from "react";
import "./App.css";
import loadingGif from "./assets/loading.gif";
import lens from "./assets/lens.png";

function App() {
  // ...

  return (
    <div className="app">
      <div className="app-container">
        <div className="spotlight__wrapper">
          // ...
          <div className="spotlight__answer">{answer && <p>{answer}</p>}</div>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The last thing we'll want to do is have the {answer} state value set back to undefined if the user ever clears the input. We'll do this with the help of the React useEffect() Hook.

import { useState, useEffect } from "react";
// ...

function App() {
  const [prompt, updatePrompt] = useState(undefined);
  const [loading, setLoading] = useState(false);
  const [answer, setAnswer] = useState(undefined);

  useEffect(() => {
    if (prompt != null && prompt.trim() === "") {
      setAnswer(undefined);
    }
  }, [prompt]);

  // ...

  return (
    // ...
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

That's all the changes we'll make to our <App /> component! There's one small thing we have to do before we can test our app.

Proxying the request

In our Vite React project, we want to make API requests to a backend server running on a different origin (i.e. a different port at localhost:5000) than the one the web application is served from (localhost:5173). However, due to the same-origin policy enforced by web browsers, such requests can be blocked for security reasons.

To get around this when working within a development environment, we can set up a reverse proxy on the frontend server (i.e. our Vite server) to forward requests to the backend server, effectively making the backend server's API available on the same origin as the frontend application.

Vite allows us to do this by modifying the server.proxy value in the Vite configuration file (which is vite.config.js).

In the vite.config.js file that already exists in our project, we'll specify the proxy to be the /api endpoint. The /api endpoint will get forwarded to http://localhost:5000.

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:5000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Now, when our front-end makes a request to /api/ask, it gets forwarded to the backend server running at http://localhost:5000/ask.

Testing our app

We've finished building our simple chatbot app. Let's test our work!

First, we need to have our Node/Express server from the last tutorial running. We'll navigate into that project directory and run node index.js to get that going.

$ custom_chat_gpt: node index.js
Enter fullscreen mode Exit fullscreen mode

We'll save our changes in our front-end app, and restart the front-end server.

$ custom_chat_gpt_frontend: npm run dev
Enter fullscreen mode Exit fullscreen mode

In the UI of our front-end app, we'll provide a prompt and press "Enter". There should be a brief loading period before the answer is then populated and shown to us!

custom_chat_gpt_frontend.gif

We can even try and ask our chatbot something more specific like "What are the best doughnuts in Toronto Canada?".

custom_chat_gpt_frontend_2.gif

Funny enough, when I search for the Castro's Lounge bakery here in Toronto, I get a bar and live-music venue, not a bakery. And Glazed & Confused Donuts appears to be in Syracuse, New York — not Toronto. It looks like there's room to fine-tune our chatbot a bit better — we'll talk about this in our last tutorial email of this series, next week 🙂.

Closing thoughts

That's it for today! 🙂

— Hassan (@djirdehh)

Top comments (0)