Open to SWE / AI Engineering roles β€” Let's talk
Skip to content

Building a Chrome Extension Popup with React and TypeScript in 2026

Software engineeringMay 1, 2026

React earns its complexity only when your popup has real dynamic state.

Building a Chrome Extension Popup with React and TypeScript in 2026

You don't always need React in a Chrome extension. A popup with three elements and one API call? Vanilla TypeScript is faster to build, lighter to ship, and simpler to debug.

But once your popup has state, conditional rendering, lists, forms, or anything that changes dynamically β€” React pays for itself.

This post covers when to use React, how to set it up with CRXJS, and how to connect your popup to the rest of the extension with typed props and messages.

Prerequisites: Working extension setup from Build Your First Chrome Extension with TypeScript and familiarity with message passing.

When React Is Worth It (and When It Isn't)

Use React when:

  • Popup has dynamic lists or conditional UI
  • Multiple interactive components (forms, toggles, tabs)
  • State updates from storage changes or messages need to re-render UI
  • You're building something like a dashboard or settings panel

Skip React when:

  • Popup displays static or near-static info
  • One or two DOM updates on load
  • Extension is a quick utility (timer, color picker, converter)
  • Bundle size is critical (more on this below)

The Bundle Size Reality

Chrome extensions have a soft 4MB package limit for the Web Store. React + ReactDOM add ~45KB gzipped (~140KB raw). That's fine for most extensions β€” it's images and assets that eat the budget, not React.

If you're paranoid about size, Preact is a 3KB drop-in alternative. But for most extensions, React's size isn't a real problem.

Setup with CRXJS and Vite

Starting from a basic CRXJS project (see the setup guide):

npm install react react-dom
npm install -D @types/react @types/react-dom @vitejs/plugin-react

Update vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";

export default defineConfig({
  plugins: [react(), crx({ manifest })],
});

Update tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "types": ["chrome-types"]
  }
}

The Popup Entry Point

Create src/popup.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./popup.tsx"></script>
  </body>
</html>

Create src/popup.tsx:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./components/App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Wiring Storage to React State

The popup needs to read settings from chrome.storage and update when they change. Custom hook:

// src/hooks/useStorage.ts
import { useState, useEffect } from "react";
import { syncStorage } from "../storage/typedStorage";
import type { SyncStorageSchema } from "../storage/schema";

export function useStorageValue<K extends keyof SyncStorageSchema>(
  key: K
): [SyncStorageSchema[K] | null, (value: SyncStorageSchema[K]) => Promise<void>] {
  const [value, setValue] = useState<SyncStorageSchema[K] | null>(null);

  useEffect(() => {
    // Load initial value
    syncStorage.get(key).then(setValue);

    // Listen for changes from other components
    const listener = (
      changes: Record<string, chrome.storage.StorageChange>,
      area: string
    ) => {
      if (area === "sync" && key in changes) {
        setValue(changes[key as string].newValue);
      }
    };
    chrome.storage.onChanged.addListener(listener);
    return () => chrome.storage.onChanged.removeListener(listener);
  }, [key]);

  const update = async (newValue: SyncStorageSchema[K]) => {
    await syncStorage.set(key, newValue);
    setValue(newValue); // Optimistic update
  };

  return [value, update];
}

This hook:

  • Loads value from storage on mount
  • Listens for changes from background or other components
  • Returns a setter that writes to storage and updates local state

Uses the typed storage wrapper from How to Persist State in a Chrome Extension.

Usage:

// src/components/App.tsx
import { useStorageValue } from "../hooks/useStorage";

export function App() {
  const [blockedSites, setBlockedSites] = useStorageValue("blockedSites");
  const [theme, setTheme] = useStorageValue("theme");

  if (blockedSites === null) return <div>Loading...</div>;

  return (
    <div style={{ width: 360, padding: 16 }}>
      <h2>Blocked Sites</h2>
      <ul>
        {blockedSites.map((site) => (
          <li key={site}>{site}</li>
        ))}
      </ul>
      <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
        Toggle Theme
      </button>
    </div>
  );
}

Sending Messages from React Components

Use the typed sendMessage wrapper from the message passing guide:

// src/components/TabInfo.tsx
import { useState, useEffect } from "react";
import { sendMessage } from "../utils/messaging";

export function TabInfo() {
  const [tabInfo, setTabInfo] = useState<{
    title: string;
    url: string;
  } | null>(null);

  useEffect(() => {
    sendMessage({ type: "GET_TAB_INFO" }).then(setTabInfo);
  }, []);

  if (!tabInfo) return <p>Loading tab info...</p>;

  return (
    <div>
      <h3>{tabInfo.title}</h3>
      <p>{tabInfo.url}</p>
    </div>
  );
}

Popup Width and Styling

Chrome popup constraints:

  • Max width: 800px, max height: 600px
  • Min width: 25px, min height: 25px
  • No scrollbar by default β€” set overflow-y: auto if content exceeds height

Set explicit dimensions on body or root:

/* src/popup.css */
body {
  width: 380px;
  min-height: 200px;
  margin: 0;
  font-family: system-ui, -apple-system, sans-serif;
}

For styling, use whatever you normally use β€” CSS modules, Tailwind, styled-components. CRXJS handles the build pipeline. Tailwind works well if you're already set up with it; just be aware it adds to bundle size.

Component Structure for Extensions

A practical structure for a medium-complexity popup:

src/
β”œβ”€β”€ popup.html
β”œβ”€β”€ popup.tsx
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ App.tsx
β”‚   β”œβ”€β”€ Settings.tsx
β”‚   β”œβ”€β”€ SiteList.tsx
β”‚   └── StatusBar.tsx
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ useStorage.ts
β”‚   └── useMessage.ts
β”œβ”€β”€ utils/
β”‚   └── messaging.ts
β”œβ”€β”€ storage/
β”‚   β”œβ”€β”€ schema.ts
β”‚   └── typedStorage.ts
β”œβ”€β”€ types/
β”‚   └── messages.ts
β”œβ”€β”€ background.ts
└── content.ts

Keep it flat. Extensions aren't SPAs β€” you don't need routing, context providers five levels deep, or state management libraries. useState + useEffect + chrome.storage covers 90% of popup state needs.

Common Mistakes

Re-rendering on every storage change. If your storage has 10 keys and you listen to all of them, you'll re-render on every unrelated change. The useStorageValue hook above filters by key.

Not handling popup lifecycle. Popup components mount and unmount every time the user opens/closes it. Don't kick off expensive operations in useEffect β€” read from storage instead. The service worker should do the heavy lifting.

Using window.localStorage. Web storage APIs don't share data with the service worker. Use chrome.storage β€” see the storage guide.

What's Next

  1. Lock down your extension β€” Chrome Extension Permissions covers what your popup and content scripts are allowed to access.
  2. Add AI β€” Building an AI-Powered Chrome Extension with Claude streams responses into a React popup.
  3. Ship it β€” How to Publish a Chrome Extension to the Web Store covers the full checklist.

This is part of the Chrome Extensions from Zero to Product series, where I walk through everything I learned building ResistGate and Amethyst β€” from first popup to published product.

Building in public

Follow the journey

as I build AI tools, products, and a serious founder life.

No spam. Unsubscribe anytime.

Building a Chrome Extension Popup with React and TypeScript in 2026 | Orlando Ascanio