Building Chrome Extensions with React

Building Chrome extensions with React provides a powerful combination of modern UI development and browser API access. This guide covers advanced React patterns specifically tailored for extension development, including custom hooks for Chrome APIs, cross-context state management, and architecture patterns that scale.

Understanding Extension Contexts {#understanding-extension-contexts}

Chrome extensions run in multiple isolated contexts, each with different capabilities and limitations. Understanding these contexts is crucial for building solid React applications:

Each context requires its own React root, but they can share state through chrome.storage and message passing.

Custom Hooks for Chrome APIs {#custom-hooks-for-chrome-apis}

Creating reusable hooks for Chrome APIs is essential for clean, maintainable extension code. Here are the essential hooks every React extension developer should implement:

useChromeStorage Hook

The storage API is the backbone of extension state persistence. Create a typed hook that handles both sync and local storage:

// hooks/useChromeStorage.ts
import { useState, useEffect, useCallback } from 'react';
import type { Storage } from 'webextension-polyfill';

type StorageArea = 'sync' | 'local' | 'managed' | 'session';

interface UseChromeStorageOptions<T> {
  key: string;
  defaultValue: T;
  area?: StorageArea;
}

export function useChromeStorage<T>({
  key,
  defaultValue,
  area = 'sync'
}: UseChromeStorageOptions<T>) {
  const [value, setValue] = useState<T>(defaultValue);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  // Get the appropriate storage area
  const getStorage = useCallback((): Storage.StorageArea => {
    if (area === 'sync') return chrome.storage.sync;
    if (area === 'local') return chrome.storage.local;
    if (area === 'session') return chrome.storage.session;
    return chrome.storage managed;
  }, [area]);

  // Load initial value
  useEffect(() => {
    const loadValue = async () => {
      try {
        const storage = getStorage();
        const result = await storage.get(key);
        setValue(result[key] ?? defaultValue);
      } catch (err) {
        setError(err instanceof Error ? err : new Error(String(err)));
      } finally {
        setIsLoading(false);
      }
    };
    loadValue();
  }, [key, defaultValue, getStorage]);

  // Set value with automatic persistence
  const setValueWithStorage = useCallback(async (newValue: T | ((prev: T) => T)) => {
    try {
      const storage = getStorage();
      const valueToStore = typeof newValue === 'function' 
        ? (newValue as (prev: T) => T)(value)
        : newValue;
      
      await storage.set({ [key]: valueToStore });
      setValue(valueToStore);
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
    }
  }, [key, value, getStorage]);

  // Remove key from storage
  const removeValue = useCallback(async () => {
    try {
      const storage = getStorage();
      await storage.remove(key);
      setValue(defaultValue);
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
    }
  }, [key, defaultValue, getStorage]);

  return {
    value,
    setValue: setValueWithStorage,
    removeValue,
    isLoading,
    error
  };
}

useChromeMessages Hook

Communication between extension contexts requires a solid message passing system. This hook provides a clean React-friendly interface:

// hooks/useChromeMessages.ts
import { useEffect, useCallback, useRef } from 'react';
import type { Runtime } from 'webextension-polyfill';

type MessageHandler = (message: unknown, sender: Runtime.MessageSender) => unknown;

interface UseChromeMessagesOptions {
  onMessage?: MessageHandler;
  onMessageExternal?: MessageHandler;
  shouldRespond?: (message: unknown) => boolean;
}

export function useChromeMessages({
  onMessage,
  onMessageExternal,
  shouldRespond
}: UseChromeMessagesOptions = {}) {
  const handlersRef = useRef<MessageHandler[]>([]);

  // Register handlers
  useEffect(() => {
    const handleMessage = (
      message: unknown, 
      sender: Runtime.MessageSender,
      sendResponse: (response?: unknown) => void
    ) => {
      // Run registered handlers
      handlersRef.current.forEach(async (handler) => {
        const result = await handler(message, sender);
        if (result !== undefined) {
          sendResponse(result);
        }
      });
    };

    // Listen for messages from other extension contexts
    const internalListener = chrome.runtime.onMessage.addListener(handleMessage);

    // Listen for messages from web pages (requires externalt messaging)
    const externalListener = onMessageExternal
      ? chrome.runtime.onMessageExternal.addListener(handleMessage)
      : undefined;

    return () => {
      internalListener();
      externalListener?.();
    };
  }, [onMessageExternal]);

  // Send message to other contexts
  const sendMessage = useCallback(async <T = unknown>(
    message: unknown,
    options?: { includeTlsId?: boolean }
  ): Promise<T> => {
    return chrome.runtime.sendMessage(message) as Promise<T>;
  }, []);

  // Send message to specific tab
  const sendMessageToTab = useCallback(async <T = unknown>(
    tabId: number,
    message: unknown
  ): Promise<T> => {
    return chrome.tabs.sendMessage(tabId, message) as Promise<T>;
  }, []);

  // Register a message handler
  const registerHandler = useCallback((handler: MessageHandler) => {
    handlersRef.current.push(handler);
    return () => {
      const index = handlersRef.current.indexOf(handler);
      if (index > -1) handlersRef.current.splice(index, 1);
    };
  }, []);

  return {
    sendMessage,
    sendMessageToTab,
    registerHandler
  };
}

useChromeTabs Hook

Managing browser tabs is a common requirement. This hook provides reactive tab state:

// hooks/useChromeTabs.ts
import { useState, useEffect, useCallback } from 'react';
import type { Tabs } from 'webextension-polyfill';

interface UseChromeTabsOptions {
  currentWindow?: boolean;
  active?: boolean;
}

export function useChromeTabs(options: UseChromeTabsOptions = {}) {
  const [tabs, setTabs] = useState<Tabs.Tab[]>([]);
  const [currentTab, setCurrentTab] = useState<Tabs.Tab | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const queryTabs = useCallback(async () => {
    setIsLoading(true);
    try {
      const query: Tabs.QueryQueryInfoType = {};
      if (options.currentWindow) query.currentWindow = true;
      if (options.active !== undefined) query.active = options.active;
      
      const queriedTabs = await chrome.tabs.query(query);
      setTabs(queriedTabs);
      
      // Find the current active tab
      const active = queriedTabs.find(t => t.active);
      setCurrentTab(active || null);
    } catch (err) {
      console.error('Failed to query tabs:', err);
    } finally {
      setIsLoading(false);
    }
  }, [options.currentWindow, options.active]);

  // Initial load
  useEffect(() => {
    queryTabs();
  }, [queryTabs]);

  // Listen for tab updates
  useEffect(() => {
    const handleCreated = (tab: Tabs.Tab) => {
      setTabs(prev => [...prev, tab]);
      if (tab.active) setCurrentTab(tab);
    };

    const handleUpdated = (tabId: number, changeInfo: Tabs.TabChangeInfo, tab: Tabs.Tab) => {
      setTabs(prev => prev.map(t => t.id === tabId ? tab : t));
      if (tab.active && changeInfo.url) setCurrentTab(tab);
    };

    const handleRemoved = (tabId: number) => {
      setTabs(prev => prev.filter(t => t.id !== tabId));
    };

    const handleActivated = (activeInfo: Tabs.TabActiveInfo) => {
      chrome.tabs.get(activeInfo.tabId).then(tab => {
        setCurrentTab(tab);
      });
    };

    chrome.tabs.onCreated.addListener(handleCreated);
    chrome.tabs.onUpdated.addListener(handleUpdated);
    chrome.tabs.onRemoved.addListener(handleRemoved);
    chrome.tabs.onActivated.addListener(handleActivated);

    return () => {
      chrome.tabs.onCreated.removeListener(handleCreated);
      chrome.tabs.onUpdated.removeListener(handleUpdated);
      chrome.tabs.onRemoved.removeListener(handleRemoved);
      chrome.tabs.onActivated.removeListener(handleActivated);
    };
  }, []);

  // Update a tab
  const updateTab = useCallback(async (
    tabId: number, 
    updateProperties: Tabs.UpdateUpdatePropertiesType
  ): Promise<Tabs.Tab> => {
    const [tab] = await chrome.tabs.update(tabId, updateProperties);
    return tab;
  }, []);

  // Create a new tab
  const createTab = useCallback(async (
    properties: Tabs.CreateCreatePropertiesType
  ): Promise<Tabs.Tab> => {
    const tab = await chrome.tabs.create(properties);
    return tab;
  }, []);

  // Close a tab
  const closeTab = useCallback(async (tabId: number): Promise<void> => {
    await chrome.tabs.remove(tabId);
  }, []);

  return {
    tabs,
    currentTab,
    isLoading,
    updateTab,
    createTab,
    closeTab,
    refetch: queryTabs
  };
}

State Management Patterns {#state-management-patterns}

Zustand for Extension State

Zustand is ideal for extensions because it doesn’t require context providers and works across different extension contexts:

// store/extensionStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface ExtensionSettings {
  theme: 'light' | 'dark' | 'system';
  notifications: boolean;
  autoSave: boolean;
  syncEnabled: boolean;
}

interface ExtensionState {
  // Settings
  settings: ExtensionSettings;
  updateSettings: (settings: Partial<ExtensionSettings>) => void;
  
  // UI State
  isPopupOpen: boolean;
  setPopupOpen: (open: boolean) => void;
  
  // Data
  cachedPages: Map<string, PageData>;
  cachePage: (url: string, data: PageData) => void;
  getCachedPage: (url: string) => PageData | undefined;
}

interface PageData {
  title: string;
  content: string;
  timestamp: number;
}

export const useExtensionStore = create<ExtensionState>()(
  persist(
    (set, get) => ({
      // Default settings
      settings: {
        theme: 'system',
        notifications: true,
        autoSave: true,
        syncEnabled: true,
      },
      
      updateSettings: (newSettings) => 
        set((state) => ({
          settings: { ...state.settings, ...newSettings }
        })),
      
      isPopupOpen: false,
      setPopupOpen: (open) => set({ isPopupOpen: open }),
      
      cachedPages: new Map(),
      
      cachePage: (url, data) => 
        set((state) => {
          const newCache = new Map(state.cachedPages);
          newCache.set(url, data);
          // Limit cache size
          if (newCache.size > 100) {
            const firstKey = newCache.keys().next().value;
            newCache.delete(firstKey);
          }
          return { cachedPages: newCache };
        }),
      
      getCachedPage: (url) => get().cachedPages.get(url),
    }),
    {
      name: 'extension-storage',
      storage: createJSONStorage(() => chrome.storage.sync),
      partialize: (state) => ({ settings: state.settings }),
    }
  )
);

Cross-Context State Synchronization

Synchronize state between popup, options page, and background using a broadcast channel pattern:

// store/syncStore.ts
import { create } from 'zustand';

// Use BroadcastChannel for cross-context communication
const stateChannel = new BroadcastChannel('extension_state_sync');

interface SyncStore {
  sharedState: Record<string, unknown>;
  updateSharedState: (updates: Record<string, unknown>) => void;
  subscribeToChanges: (callback: (state: Record<string, unknown>) => void) => () => void;
}

export const useSyncStore = create<SyncStore>((set, get) => {
  // Listen for state updates from other contexts
  stateChannel.onmessage = (event) => {
    if (event.data.type === 'STATE_UPDATE') {
      set({ sharedState: event.data.payload });
    }
  };

  return {
    sharedState: {},
    
    updateSharedState: (updates) => {
      const newState = { ...get().sharedState, ...updates };
      set({ sharedState: newState });
      
      // Broadcast to other contexts
      stateChannel.postMessage({
        type: 'STATE_UPDATE',
        payload: newState,
      });
    },
    
    subscribeToChanges: (callback) => {
      const listener = (event: MessageEvent) => {
        if (event.data.type === 'STATE_UPDATE') {
          callback(event.data.payload);
        }
      };
      stateChannel.addEventListener('message', listener);
      return () => stateChannel.removeEventListener('message', listener);
    },
  };
});

Extension Architecture Patterns {#extension-architecture-patterns}

Feature-Based Directory Structure

Organize your extension by feature rather than by file type:

src/
 features/
    bookmarking/
       components/
          BookmarkButton.tsx
          BookmarkList.tsx
       hooks/
          useBookmarks.ts
       store/
          bookmarkStore.ts
       index.ts
    note-taking/
       components/
       hooks/
       store/
       index.ts
    settings/
 shared/
    components/
    hooks/
    utils/
 popup/
 options/
 sidepanel/
 background/
 content/

Background-Initiated UI Updates

For features that require the background script to drive UI updates:

// background/stateManager.ts
class ExtensionStateManager {
  private port: chrome.runtime.Port | null = null;
  private listeners: Map<string, Set<(data: unknown) => void>> = new Map();

  connect(port: chrome.runtime.Port) {
    this.port = port;
    
    port.onMessage.addListener((message) => {
      const { type, payload, feature } = message;
      
      if (type === 'STATE_UPDATE' && feature) {
        this.notifyListeners(feature, payload);
      }
    });

    port.onDisconnect.addListener(() => {
      this.port = null;
    });
  }

  broadcast(feature: string, data: unknown) {
    if (this.port) {
      this.port.postMessage({
        type: 'STATE_UPDATE',
        feature,
        payload: data,
      });
    }
  }

  subscribe(feature: string, callback: (data: unknown) => void) {
    if (!this.listeners.has(feature)) {
      this.listeners.set(feature, new Set());
    }
    this.listeners.get(feature)!.add(callback);

    return () => {
      this.listeners.get(feature)?.delete(callback);
    };
  }

  private notifyListeners(feature: string, data: unknown) {
    this.listeners.get(feature)?.forEach(callback => callback(data));
  }
}

Content Script React Integration {#content-script-react-integration}

Injecting React into web pages requires special handling:

// content-script/inject.tsx
import { createRoot } from 'react-dom/client';
import { ContentApp } from './ContentApp';

function mountReactComponent() {
  // Create a container that won't interfere with page styles
  const container = document.createElement('div');
  container.id = 'extension-root';
  container.style.cssText = `
    position: fixed;
    z-index: 2147483647;
    top: 0;
    right: 0;
  `;
  
  // Use Shadow DOM for style isolation
  const shadowRoot = container.attachShadow({ mode: 'open' });
  const mountPoint = document.createElement('div');
  shadowRoot.appendChild(mountPoint);
  
  // Inject styles into Shadow DOM
  const styleSheet = document.createElement('style');
  styleSheet.textContent = `
    /* Extension styles - isolated from page */
    .extension-button {
      background: #4285f4;
      color: white;
      border: none;
      padding: 8px 16px;
      border-radius: 4px;
      cursor: pointer;
    }
  `;
  shadowRoot.appendChild(styleSheet);
  
  document.body.appendChild(container);
  
  // Mount React
  const root = createRoot(mountPoint);
  root.render(<ContentApp />);
  
  return () => {
    root.unmount();
    container.remove();
  };
}

// Only run once per page
if (!document.getElementById('extension-root')) {
  mountReactComponent();
}

Performance Optimization {#performance-optimization}

Memoization Strategies

React extensions face unique performance challenges due to multiple contexts and communication overhead:

// hooks/useDebouncedStorage.ts
import { useEffect, useRef, useCallback } from 'react';

export function useDebouncedStorage<T>(
  value: T,
  key: string,
  delay: number = 500
) {
  const timeoutRef = useRef<number | null>(null);
  const valueRef = useRef(value);

  // Keep value ref updated
  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  // Debounced save
  useEffect(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    timeoutRef.current = window.setTimeout(async () => {
      try {
        await chrome.storage.sync.set({ [key]: valueRef.current });
      } catch (error) {
        console.error('Failed to save to storage:', error);
      }
    }, delay);

    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, [key, delay]);

  return value;
}

Lazy Loading Extension Features

Reduce initial load time by lazy loading features:

// components/LazyFeature.tsx
import { lazy, Suspense, useState, useEffect } from 'react';

const LazyFeature = lazy(() => import('./HeavyFeature'));

function FeatureLoader() {
  const [shouldLoad, setShouldLoad] = useState(false);

  useEffect(() => {
    // Load on user interaction
    const handleInteraction = () => {
      setShouldLoad(true);
      document.removeEventListener('click', handleInteraction);
    };
    
    document.addEventListener('click', handleInteraction);
    return () => document.removeEventListener('click', handleInteraction);
  }, []);

  if (!shouldLoad) {
    return <button onClick={() => setShouldLoad(true)}>Load Feature</button>;
  }

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyFeature />
    </Suspense>
  );
}

Related Guides {#related-guides}


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