Image description

Build a local chatbot with React, openAI, and indexed DB (using Dexie.js)

Everybody wants to use AI, and I’m sure many clients will ask you to integrate a chat or agent into their products.

Quick overview

With the rise of large language models (LLMs) like OpenAI, Gemini, and DeepSeek, many companies are looking to integrate chatbots into their apps not just as support tools, but as intelligent advisors or agents.

These chatbots can offer personalized, value-added services by giving the LLM context from the app itself, making conversations more relevant and useful to the user.

In this post, we will create a chatbot using React and IndexedDB. We will use a wrapper called Dexie.js to help us implement our local database for storing information persistently..

However, before we proceed, we need to understand what IndexedDB is.

IndexedDB db is basically a low-level database api client to store large volumes of data on the browser, including files and blogs (images)

One of the main benefits is that it has non-blocking operation, all transactions are async compared with localStorage

Here is a comparison table between using localStorage and IndexedDB.

Feature LocalStorage IndexedDB
Data Type Strings Objects (JSON, files, blobs)
Capacity ~5MB Hundreds of MB or more, potentially up to 50% of disk space
API Complexity Simple (synchronous, few methods) Complex (asynchronous, transactions, multiple objects)

Dexie.js

It is a JavaScript library which, at the same time, is a wrapper around IndexedDB the main benefit of using it is that it provides a practical and more intuitive api, and it abstracts a lot of the complexity of the native IndexedDB API, making async operations and error handling easier

Stack overview of the project

  • React

  • open Ai library

  • dexie.js

  • Vite + tailwind

This is the app we will build on this post

Image description

As we can see, all the questions and answers are stored in the browser using IndexedDB.

Image description

We will also need to create a .env file in our app to set our OpenAPI key.

VITE_OPENAI_API_KEY='your open api key'

Remember to add this file to your .gitignore file in order not to push this code to the repo

Never expose your OpenAI key in production builds. Utilize environment variables.

Setting up our local database

// db.ts
import Dexie, { type EntityTable } from 'dexie';

export interface ChatMessage {
  id?: number;
  question: string;
  answer: string;
  createdAt?: Date;
}

const db = new Dexie('chatDatabase') as Dexie & {
  chatMessages: EntityTable<ChatMessage, 'id'>; // type and primary key
};

db.version(1).stores({
  // ++ means autoincrement primary key
  chatMessages: '++id, question, answer, createdAt',
});

export { db };

Now we will proceed with the app code.

We will need some variables to save messages that come from our db, the questions, search and isLoading state.


  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [question, setQuestion] = useState('');
  const [search, setSearch] = useState('');
  const [isLoading, setIsLoading] = useState(false);

We define a function called getMessages to fetch all saved chat messages from the local IndexedDB using Dexie. Then, we call this function inside a useEffect to load the messages once the component mounts

  const getMessages = async () => {
      const messages = await db.chatMessages.toArray(); // fetch all messages from the "chatMessages" table
    setMessages(messages); // store them in local state to render
  };


  useEffect(() => {
    getMessages(); // runs once when the component is mounted
  }, []);

This is the code to render the app

  return (
    <div className="container mx-auto">
      <h1 className="text-3xl font-bold text-center mt-5">Open AI chat</h1>
      <div className="flex flex-col items-center justify-center h-screen">
        <div className="w-full max-w-md">
          <input
            type="text"
            placeholder="find a question"
            className="w-full p-2 border border-gray-300 rounded-md mt-2"
            onChange={(e) => setSearch(e.target.value)}
          />
          <div className="flex flex-col h-[60vh] overflow-y-auto bg-gray-100 p-2 mt-2 rounded-md">
            {renderMessages()}
          </div>
          <div className="mb-4">
            <input
              type="text"
              placeholder="Enter your message"
              className="w-full p-2 border border-gray-300 rounded-md mt-2"
              onChange={(e) => setQuestion(e.target.value)}
            />
            <button
              disabled={isLoading || !question}
              onClick={handleSend}
              className="w-full bg-blue-500 text-white p-2 rounded-md mt-2"
            >
              {isLoading ? 'Loading...' : 'Send'}
            </button>
          </div>
        </div>
      </div>
    </div>
  );

Inside this code we have a function called renderMessages

The renderMessage function displays messages when we type a term in the search input or when messages come from the getMessages function, as shown in the following code block.

 const renderMessages = () => {
    const messagesToShow = search ? filteredMessages : messages;

    if (messagesToShow.length === 0) {
      return (
        <div className="text-center text-gray-500 mt-4">
          {search
            ? 'No messages found matching your search.'
            : 'No messages yet. Start a conversation!'}
        </div>
      );
    }

    return messagesToShow.map((message) => (
      <MessageItem
        key={message.id}
        message={message}
        showDelete={!search} // Only show delete button for regular view, not search results
      />
    ));
  };

This is the code for MessageItem component

  const MessageItem = ({
    message,
    showDelete = true,
  }: {
    message: ChatMessage;
    showDelete?: boolean;
  }) => (
    <div className="mt-2 flex flex-col p-2 bg-gray-200 rounded-md">
      <div className="flex justify-between items-center">
        <p className="text-left font-bold">{message.question}</p>
        {showDelete && (
          <FaTrash
            className="text-red-500 cursor-pointer"
            onClick={() => handleDelete(message.id)}
          />
        )}
      </div>
      <p className="text-left">{message.answer}</p>
    </div>
  );

This is the handleSend function. It retrieves the question response from the OpenAI API. For more details, check the OpenAI documentation.

We use the safeAwait function for error handling. If you want to learn more, read my other post where I explain how it works:

https://dev.to/angeldavid218/stop-repeating-trycatch-this-typescript-wrapper-makes-error-handling-easy-3eep

  const handleSend = async () => {
    setIsLoading(true);
    const [response, err] = await safeAwait(
      fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
        },
        body: JSON.stringify({
          model: 'o4-mini',
          messages: [{ role: 'user', content: question }],
        }),
      })
    );
    if (err || !response?.ok) {
      console.error(err);
      return;
    }
    const data = await response?.json();
    // once get the response it gets saved to our local db 
    await addMessage(question, data.choices[0].message.content || '');
    setIsLoading(false);
    setQuestion('');
  };

This is the addMessage function

  const addMessage = async (question: string, answer: string) => {
    const [, err] = await safeAwait(
      db.chatMessages.add({
        question,
        answer,
        createdAt: new Date(),
      })
    );
    if (err) {
      console.error(err);
      return;
    }
    getMessages();
  };

Function to delete a question

  const handleDelete = async (id: number | undefined) => {
    if (id === undefined) {
      console.error('Cannot delete message: ID is undefined');
      return;
    }

    const [, err] = await safeAwait(db.chatMessages.delete(id));
    if (err) {
      console.error('Failed to delete message:', err);
      // You could add user feedback here, like a toast notification
      return;
    }

    // Refresh the messages list after successful deletion
    await getMessages();
  };

And when the user is searching for a specific question

  const filteredMessages = messages.filter((message) =>
    message.question.toLowerCase().includes(search.toLowerCase())
  );

This is the complete app code

import { useEffect, useState } from 'react';
import { db, type ChatMessage } from './db';
import { safeAwait } from './utils/safeAwait';
import { FaTrash } from 'react-icons/fa';

function App() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [question, setQuestion] = useState('');
  const [search, setSearch] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const getMessages = async () => {
    const messages = await db.chatMessages.toArray();
    setMessages(messages);
  };

  const addMessage = async (question: string, answer: string) => {
    const [, err] = await safeAwait(
      db.chatMessages.add({
        question,
        answer,
        createdAt: new Date(),
      })
    );
    if (err) {
      console.error(err);
      return;
    }
    getMessages();
  };

  const handleSend = async () => {
    setIsLoading(true);
    const [response, err] = await safeAwait(
      fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
        },
        body: JSON.stringify({
          model: 'o4-mini',
          messages: [{ role: 'user', content: question }],
        }),
      })
    );
    if (err || !response?.ok) {
      console.error(err);
      return;
    }
    const data = await response?.json();
    await addMessage(question, data.choices[0].message.content || '');
    setIsLoading(false);
    setQuestion('');
  };

  const handleDelete = async (id: number | undefined) => {
    if (id === undefined) {
      console.error('Cannot delete message: ID is undefined');
      return;
    }

    const [, err] = await safeAwait(db.chatMessages.delete(id));
    if (err) {
      console.error('Failed to delete message:', err);
      // You could add user feedback here, like a toast notification
      return;
    }

    // Refresh the messages list after successful deletion
    await getMessages();
  };

  useEffect(() => {
    getMessages();
  }, []);

  const filteredMessages = messages.filter((message) =>
    message.question.toLowerCase().includes(search.toLowerCase())
  );

  const MessageItem = ({
    message,
    showDelete = true,
  }: {
    message: ChatMessage;
    showDelete?: boolean;
  }) => (
    <div className="mt-2 flex flex-col p-2 bg-gray-200 rounded-md">
      <div className="flex justify-between items-center">
        <p className="text-left font-bold">{message.question}</p>
        {showDelete && (
          <FaTrash
            className="text-red-500 cursor-pointer"
            onClick={() => handleDelete(message.id)}
          />
        )}
      </div>
      <p className="text-left">{message.answer}</p>
    </div>
  );

  const renderMessages = () => {
    const messagesToShow = search ? filteredMessages : messages;

    if (messagesToShow.length === 0) {
      return (
        <div className="text-center text-gray-500 mt-4">
          {search
            ? 'No messages found matching your search.'
            : 'No messages yet. Start a conversation!'}
        </div>
      );
    }

    return messagesToShow.map((message) => (
      <MessageItem
        key={message.id}
        message={message}
        showDelete={!search} // Only show delete button for regular view, not search results
      />
    ));
  };

  return (
    <div className="container mx-auto">
      <h1 className="text-3xl font-bold text-center mt-5">Open AI chat</h1>
      <div className="flex flex-col items-center justify-center h-screen">
        <div className="w-full max-w-md">
          <input
            type="text"
            placeholder="find a question"
            className="w-full p-2 border border-gray-300 rounded-md mt-2"
            onChange={(e) => setSearch(e.target.value)}
          />
          <div className="flex flex-col h-[60vh] overflow-y-auto bg-gray-100 p-2 mt-2 rounded-md">
            {renderMessages()}
          </div>
          <div className="mb-4">
            <input
              type="text"
              placeholder="Enter your message"
              className="w-full p-2 border border-gray-300 rounded-md mt-2"
              onChange={(e) => setQuestion(e.target.value)}
            />
            <button
              disabled={isLoading || !question}
              onClick={handleSend}
              className="w-full bg-blue-500 text-white p-2 rounded-md mt-2"
            >
              {isLoading ? 'Loading...' : 'Send'}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;

Conclusion

With this setup, you have a solid starting point to create your chatbot. You now have a React app that:

  • Connects to the OpenAI API

  • Stores chat history locally using IndexedDB (via Dexie.js)

  • Supports search and deletion for better UX

  • Uses clean async error handling with a reusable wrapper

Want to extend it? try:

  • Adding chat roles (system/user/assistant)

  • Exporting chat history

Check out the full code on GitHub

https://github.com/angeldavid218/ai-chat

Thanks for reading, and see you in the next one!

Similar Posts