Building Chrome Extensions with React — Developer Guide

23 min read

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

Chrome extensions run in multiple isolated contexts, each with different capabilities and limitations. Understanding these contexts is crucial for building robust 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

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 robust 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

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

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

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

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>
  );
}

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

No previous article
No next article