Skip to main content
Engineering

How to Add an AI Chatbot to a React App (Without Leaking Widgets)

Cortexvia Engineering11 Jun 20264 min read

Dropping a third-party widget into a React app is where most integrations quietly go wrong. It works on the first page, then you navigate, and there are two launchers. Navigate again and there are three.

The fix is not complicated, but it is specific. Here is how to do it properly, and why each line is there.

The shape of the problem

The widget is imperative: you call mount() and it puts DOM on the page. React is declarative and owns the DOM. Those two models do not automatically agree, and the disagreements show up as:

  • Duplicate widgets after client-side navigation
  • Two launchers in development but one in production
  • Errors on unmount when the widget outlives its container
  • The script loading repeatedly on every component mount

All four come from the same root cause: no cleanup, or cleanup that does not account for async.

Step 1: Load the SDK once per page

The CDN files should be requested once, not once per component instance. A module-scope promise gives every caller the same in-flight request:

js
// chatbotSdk.js
let sdkPromise = null;

export function loadChatbotSdk() {
  if (sdkPromise) return sdkPromise;

  sdkPromise = new Promise((resolve, reject) => {
    if (window.ChatbotSDK) return resolve(window.ChatbotSDK);

    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = "https://cdn.cortexvia.in/chatbotsdk-web.css";
    document.head.appendChild(link);

    const script = document.createElement("script");
    script.src = "https://cdn.cortexvia.in/chatbotsdk-web.iife.js";
    script.onload = () => resolve(window.ChatbotSDK);
    script.onerror = () => reject(new Error("Failed to load Chatbot SDK"));
    document.head.appendChild(script);
  });

  return sdkPromise;
}

Caching the promise, not the result, is the important part. Two components mounting in the same tick share one network request instead of racing.

Step 2: Mount in an effect, unmount in the cleanup

jsx
import { useEffect, useRef } from "react";
import { loadChatbotSdk } from "./chatbotSdk";

export function Chatbot({ chatbotKey }) {
  const containerRef = useRef(null);
  const handleRef = useRef(null);

  useEffect(() => {
    let cancelled = false;

    loadChatbotSdk().then((sdk) => {
      if (cancelled || !sdk?.mount || !containerRef.current) return;

      handleRef.current = sdk.mount(containerRef.current, {
        chatbotKey,
        config: { mode: "chatbot" },
      });
    });

    return () => {
      cancelled = true;
      handleRef.current?.unmount();
      handleRef.current = null;
    };
  }, [chatbotKey]);

  return <div ref={containerRef} />;
}

Three things are doing real work here.

unmount() in the cleanup. This is what stops a route change leaving a widget behind. Without it, every navigation adds another.

The cancelled flag. The component can unmount while the CDN request is still in flight. Without the flag you mount into a node React has already discarded, which throws or leaks.

chatbotKey in the dependency array. Change the key, and the effect tears down the old widget before creating the new one.

Step 3: Know what StrictMode does

In development, React 18 and 19 run effects twice: mount, cleanup, mount. This is deliberate. It surfaces exactly the missing-cleanup bugs described above.

If you skip unmount(), you will see two launchers in dev and one in production. That looks like a widget bug and is not; it is your cleanup. The code above handles it correctly.

Do not disable StrictMode to make the symptom go away. It is telling you the truth.

Updating without remounting

If you need to change configuration on a live widget, switching modes or changing the placeholder, use update() rather than tearing down and remounting:

jsx
useEffect(() => {
  handleRef.current?.update({
    chatbotKey,
    config: { mode: "chat", inputPlaceholder: "Ask anything..." },
  });
}, [someSetting]);

update() takes the same shape as mount(). Remounting loses the conversation; updating does not.

Floating vs inline

Two modes, and the choice affects your layout.

mode: "chatbot" is a floating launcher pinned to a corner. Best for site-wide support. Put it once in your root layout, not per route.

mode: "chat" is an inline panel that renders directly in your container and stays open. Best for a dedicated support page or an AI search panel. The container needs a stable height; inline mode fills its parent, and a parent with no height renders as nothing.

jsx
<div ref={containerRef} style={{ height: "72vh" }} />

That missing height is the single most common inline-mode bug.

Where to put it

For a floating widget, mount once at the top of your tree:

jsx
function App() {
  return (
    <>
      <Routes>{/* ... */}</Routes>
      <Chatbot chatbotKey="ctx-cb-xxxxxxxxxxxxxxxx" />
    </>
  );
}

Outside the router, so navigation does not remount it. Inside the router, every route change tears the widget down and rebuilds it, which works but throws away the conversation and looks janky.

A checklist before you ship

  • Navigate between routes several times, then count the launchers. Should be one.
  • Check the network tab: the CDN files should load once, not per navigation.
  • In inline mode, confirm the container has a height.
  • Confirm it survives a fast route change during the initial load; that is the cancelled flag doing its job.

Frequently asked questions

Can I use this with Vite, Create React App, or Remix? Yes. Nothing here depends on the bundler. Remix and other server-rendering frameworks need the mount to stay inside useEffect, which it already does. Next.js has its own wrinkles, covered in the Next.js guide.

Do I need TypeScript types for window.ChatbotSDK? A minimal declaration is enough: declare global { interface Window { ChatbotSDK?: { mount: Function } } }. The React guide page has a typed drop-in component.

Where does the chatbot key come from? From the Chatbots area after your first source has processed. The free plan gives you a real key to build against.

Ready to Get Started?

Build your chatbot in minutes with Cortexvia. Free tier available.