Chrome Extension Content Script React — Best Practices

5 min read

Integrating React into Chrome Extension Content Scripts

The Challenge

Content scripts run in the context of the web page, not the extension. The page already has its own DOM, and injecting React directly into the page DOM creates two problems: style leakage (your CSS affects the page, page CSS affects your UI) and potential conflicts if the page already uses React.


Shadow DOM Mounting

React needs a DOM root to render into. For content scripts, create a Shadow DOM host element:

// content/scripts/react-mount.tsx
import { createRoot } from 'react-dom/client';

function createShadowHost() {
  const host = document.createElement('div');
  host.id = 'my-extension-root';
  host.style.cssText = 'position: fixed; z-index: 999999;';
  document.body.appendChild(host);
  
  const shadow = host.attachShadow({ mode: 'open' });
  return shadow;
}

const shadowRoot = createShadowHost();
const container = document.createElement('div');
shadowRoot.appendChild(container);

const root = createRoot(container);
root.render(<App />);

The Shadow DOM provides style isolation — global page CSS won’t affect your React components.


CSS-in-JS with Shadow DOM

Use Emotion or styled-components with the container option to inject styles into Shadow DOM:

/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

const widgetStyle = css`
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  padding: 16px;
  font-family: system-ui, sans-serif;
`;

// Configure Emotion to inject into shadowRoot
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';

const cache = createCache({ container: shadowRoot });

Bundling Configuration

Content scripts must be bundled as standalone IIFE (Immediately Invoked Function Expression) — they can’t use ES modules. Configure your bundler:

// vite.config.js content script entry
export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        content: 'src/content/index.tsx',
      },
      output: {
        format: 'iife',
        entryFileNames: 'content.js',
      },
    },
  },
});

In manifest.json, reference the bundled file:

{
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "run_at": "document_end"
  }]
}

State Management

For content scripts, avoid Redux/Context — they add overhead. Use lightweight alternatives:

// Using zustand (no provider needed)
import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}));

// Usage in component — no Provider wrapper required
function Counter() {
  const { count, increment } = useStore();
  return <button onClick={increment}>{count}</button>;
}

Jotai is another excellent choice for atomic state management.


Communicating with Background

// content/hooks/useBackgroundMessage.ts
import { useEffect, useState } from 'react';

export function useBackgroundMessage(channel: string) {
  const [message, setMessage] = useState<any>(null);

  useEffect(() => {
    const handler = (msg: any) => {
      if (msg.channel === channel) setMessage(msg.data);
    };
    chrome.runtime.onMessage.addListener(handler);
    return () => chrome.runtime.onMessage.removeListener(handler);
  }, [channel]);

  const send = (data: any) => 
    chrome.runtime.sendMessage({ channel, data });

  return { message, send };
}

Hot Module Replacement

HMR doesn’t work reliably in content scripts — the script executes in the page context, not the extension. Each reload requires re-injecting into the page. Use standard development patterns: build → reload extension → refresh page.


Avoiding React Conflicts

If the page uses React, your content script’s React instance could conflict. Shadow DOM isolation prevents this — each React instance is entirely separate. The page’s React won’t mount into your Shadow DOM, and your React won’t be affected by the page’s React.


Bundle Size

React adds ~40KB (minified) to your content script. Consider alternatives:

// Using Preact with Vite
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
  plugins: [preact()],
  resolve: { alias: { react: 'preact/compat' } },
});

Cross-References

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.