Jotai Atomic State in Chrome Extensions: Complete Implementation Guide
State management is one of the most challenging aspects of building Chrome extensions. Unlike traditional web applications, Chrome extensions run in multiple contexts—popup windows, background service workers, content scripts, and options pages—all needing to share and synchronize state. While Redux and Context API have been go-to solutions for years, Jotai atomic state offers a more elegant approach that is perfectly suited for the unique architecture of Chrome extensions.
In this comprehensive guide, we will explore how to implement jotai chrome extension patterns, leverage atomic state for extension-wide data sharing, and build a robust state management system that scales with your extension’s complexity.
Why State Management Matters in Chrome Extensions
Before diving into Jotai, it is essential to understand why state management is particularly challenging in Chrome extensions. When building a typical web application, you have a single JavaScript runtime with a unified component tree. In contrast, Chrome extensions involve multiple isolated contexts that communicate through message passing.
Consider a typical Chrome extension with these components:
- Popup — The small window that opens when clicking the extension icon
- Background Service Worker — Handles events, manages storage, and coordinates between components
- Content Scripts — Run in the context of web pages
- Options Page — Configuration interface
- DevTools Panel — For extensions that add debugging features
Each of these contexts maintains its own JavaScript execution environment. Without a proper state management strategy, keeping data synchronized across these contexts becomes a nightmare of event listeners and message passing code.
This is where atomic state extension architecture shines. Jotai provides a primitive-based approach that makes sharing state across contexts straightforward and intuitive.
What is Jotai?
Jotai is an atomic state management library for React that was created by Poimandres (the team behind React Spring). Unlike Redux, which uses a single global store, Jotai takes a more granular approach by organizing state into independent atoms.
Core Concepts of Jotai
At its core, Jotai introduces two fundamental concepts:
- Atoms — The basic unit of state. An atom holds a piece of state and notifies subscribers when that state changes.
- Providers — The context that holds atoms and provides them to React components.
The beauty of Jotai lies in its simplicity. Each atom is completely independent, meaning you only subscribe to the specific piece of state you need. This eliminates unnecessary re-renders and makes code more maintainable.
import { atom } from 'jotai';
// Simple atom for a single value
const countAtom = atom(0);
// Derived atom that computes value from other atoms
const doubleCountAtom = atom((get) => get(countAtom) * 2);
This atomic approach translates remarkably well to jotai popup state management and extension-wide state sharing.
Setting Up Jotai in Your Chrome Extension
Now let us explore how to set up Jotai in a Chrome extension project. We will assume you are using a modern build tool like Vite with React.
Installation
First, install Jotai in your extension project:
npm install jotai
# or
yarn add jotai
If you are using TypeScript (recommended for extensions), you will get full type inference out of the box.
Project Structure
A well-organized Chrome extension with Jotai might look like this:
my-extension/
├── manifest.json
├── popup/
│ ├── Popup.tsx
│ └── hooks.ts
├── background/
│ └── service-worker.ts
├── content/
│ └── content-script.tsx
├── shared/
│ ├── atoms/
│ │ ├── userAtom.ts
│ │ ├── settingsAtom.ts
│ │ └── uiAtom.ts
│ └── store.ts
└── components/
The key insight here is the shared/ directory, which contains atoms that can be imported across different extension contexts.
Creating Atoms for Chrome Extension State
Let us create a comprehensive atom structure for a typical Chrome extension. We will build atoms that represent common extension state: user preferences, UI state, and synchronization status.
User Preferences Atom
// shared/atoms/userAtom.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export interface UserPreferences {
theme: 'light' | 'dark' | 'system';
notifications: boolean;
autoSave: boolean;
language: string;
}
const defaultPreferences: UserPreferences = {
theme: 'system',
notifications: true,
autoSave: true,
language: 'en',
};
// atomWithStorage automatically persists to chrome.storage
export const userPreferencesAtom = atomWithStorage<UserPreferences>(
'userPreferences',
defaultPreferences
);
// Derived atom for theme value
export const themeAtom = atom((get) => {
const prefs = get(userPreferencesAtom);
if (prefs.theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return prefs.theme;
});
UI State Atoms
For jotai popup state, you need atoms that track the UI state within the popup:
// shared/atoms/uiAtom.ts
import { atom } from 'jotai';
export type ModalType = 'settings' | 'confirm-delete' | 'upgrade-pro' | null;
export interface UIState {
isLoading: boolean;
activeModal: ModalType;
sidebarOpen: boolean;
searchQuery: string;
}
export const uiStateAtom = atom<UIState>({
isLoading: false,
activeModal: null,
sidebarOpen: true,
searchQuery: '',
});
// Convenience atoms for common UI state
export const isLoadingAtom = atom(
(get) => get(uiStateAtom).isLoading
);
export const activeModalAtom = atom(
(get) => get(uiStateAtom).activeModal,
(get, set, modal: ModalType) => {
const current = get(uiStateAtom);
set(uiStateAtom, { ...current, activeModal: modal });
}
);
export const searchQueryAtom = atom(
(get) => get(uiStateAtom).searchQuery,
(get, set, query: string) => {
const current = get(uiStateAtom);
set(uiStateAtom, { ...current, searchQuery: query });
}
);
Extension-Wide Data Atoms
For data that needs to be shared across the entire extension:
// shared/atoms/dataAtom.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export interface TabData {
id: number;
title: string;
url: string;
favicon?: string;
lastAccessed: number;
}
export interface ExtensionData {
savedTabs: TabData[];
syncStatus: 'idle' | 'syncing' | 'error';
lastSyncTime: number | null;
errorMessage?: string;
}
const defaultData: ExtensionData = {
savedTabs: [],
syncStatus: 'idle',
lastSyncTime: null,
};
export const extensionDataAtom = atomWithStorage<ExtensionData>(
'extensionData',
defaultData
);
// Derived atoms for specific data
export const savedTabsAtom = atom(
(get) => get(extensionDataAtom).savedTabs
);
export const syncStatusAtom = atom(
(get) => get(extensionDataAtom).syncStatus
);
export const lastSyncTimeAtom = atom(
(get) => get(extensionDataAtom).lastSyncTime
);
Using Jotai in the Popup Component
Now let us see how to use these atoms in your popup component:
// popup/Popup.tsx
import React from 'react';
import { Provider } from 'jotai';
import { useAtom } from 'jotai';
import {
savedTabsAtom,
searchQueryAtom,
isLoadingAtom,
themeAtom
} from '../shared/atoms';
import { TabList } from './components/TabList';
import { SearchBar } from './components/SearchBar';
import { LoadingSpinner } from './components/LoadingSpinner';
import './popup.css';
const PopupContent: React.FC = () => {
const [tabs] = useAtom(savedTabsAtom);
const [searchQuery] = useAtom(searchQueryAtom);
const [isLoading] = useAtom(isLoadingAtom);
const [theme] = useAtom(themeAtom);
// Filter tabs based on search query
const filteredTabs = tabs.filter(tab =>
tab.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
tab.url.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className={`popup-container ${theme}`}>
<header className="popup-header">
<h1>Tab Manager</h1>
<SearchBar />
</header>
<main className="popup-main">
{isLoading ? (
<LoadingSpinner />
) : (
<TabList tabs={filteredTabs} />
)}
</main>
<footer className="popup-footer">
<span>{tabs.length} tabs saved</span>
<button onClick={() => window.close()}>Close</button>
</footer>
</div>
);
};
export const Popup: React.FC = () => {
return (
<Provider>
<PopupContent />
</Provider>
);
};
Cross-Context State Sharing with Jotai
One of the most powerful features of Jotai for Chrome extensions is the ability to share atoms across different contexts. However, because Chrome extension contexts are isolated, we need a bridge.
The Storage Atom Pattern
The key is using atomWithStorage from Jotai utils, which automatically synchronizes with chrome.storage:
// shared/store.ts
import { createStore } from 'jotai';
// Create a store that can be used outside of React
export const store = createStore();
// This store can be imported in background scripts
// to interact with atoms from there
Background Script Integration
In your background service worker, you can interact with the same atoms:
// background/service-worker.ts
import { store } from '../shared/store';
import { extensionDataAtom, savedTabsAtom } from '../shared/atoms';
import { atom } from 'jotai';
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SAVE_TAB') {
const currentTabs = store.get(savedTabsAtom);
const newTab = {
id: message.tabId,
title: message.title,
url: message.url,
lastAccessed: Date.now(),
};
store.set(savedTabsAtom, [...currentTabs, newTab]);
sendResponse({ success: true });
}
if (message.type === 'GET_TABS') {
const tabs = store.get(savedTabsAtom);
sendResponse({ tabs });
}
return true;
});
// Listen for storage changes (for cross-context sync)
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'local') {
// Notify all contexts of the change
// This keeps the in-memory atoms in sync with chrome.storage
}
});
Best Practices for Jotai in Chrome Extensions
When implementing atomic state extension architecture in Chrome extensions, follow these best practices:
1. Use atomWithStorage for Persistent Data
Always use atomWithStorage for data that needs to persist across browser sessions:
import { atomWithStorage } from 'jotai/utils';
// ✅ Good - persists to chrome.storage
const settingsAtom = atomWithStorage('settings', defaultSettings);
// ❌ Bad - lost on extension restart
const settingsAtom = atom(defaultSettings);
2. Separate UI State from Data State
Keep your atoms organized by concern:
// atoms/uiAtom.ts - UI-only state (doesn't need persistence)
export const isModalOpenAtom = atom(false);
// atoms/dataAtom.ts - Application data (should persist)
export const userDataAtom = atomWithStorage('userData', {});
3. Use Derived Atoms for Computed Values
Leverage derived atoms to compute values rather than duplicating logic:
// ✅ Good - single source of truth
const tabCountAtom = atom((get) => get(tabsAtom).length);
// ❌ Bad - computed in component, duplicated
const TabList: React.FC = () => {
const count = tabs.length; // computed everywhere
};
4. Implement Error Boundaries
Chrome extensions should handle errors gracefully:
// atoms/uiAtom.ts
export const errorAtom = atom<string | null>(null);
export const setErrorAtom = atom(
null,
(get, set, error: string) => {
set(errorAtom, error);
// Auto-clear after 5 seconds
setTimeout(() => set(errorAtom, null), 5000);
}
);
Jotai vs Redux: Which to Choose for Chrome Extensions
When deciding between Jotai and Redux for your jotai chrome extension, consider these factors:
| Aspect | Jotai | Redux |
|---|---|---|
| Bundle Size | ~3KB | ~7KB+ (with Redux Toolkit) |
| Boilerplate | Minimal | Moderate |
| Learning Curve | Low | Medium |
| Debugging | Good (React DevTools) | Excellent (Redux DevTools) |
| TypeScript | Excellent inference | Excellent with TS |
| Chrome Storage | Built-in via utils | Manual implementation |
For most Chrome extensions, Jotai’s simplicity and smaller bundle size make it the better choice. The atomic model aligns well with the compartmentalized nature of extension components.
Performance Optimization Tips
1. Split Large Data into Multiple Atoms
Instead of a single large atom:
// ❌ Bad - causes re-render on any change
const allDataAtom = atomWithStorage('allData', {
tabs: [],
bookmarks: [],
history: [],
settings: {},
});
// ✅ Good - subscribe only to what you need
const tabsAtom = atomWithStorage('tabs', []);
const bookmarksAtom = atomWithStorage('bookmarks', []);
const settingsAtom = atomWithStorage('settings', {});
2. Use useAtomValue for Read-Only Data
When you only need to read a value, use useAtomValue instead of useAtom:
// ✅ Better performance - no setter returned
const theme = useAtomValue(themeAtom);
// Use this when you need both read and write
const [theme, setTheme] = useAtom(themeAtom);
3. Implement Virtualization for Large Lists
If your extension manages many items, combine Jotai with virtual scrolling:
import { useVirtualizer } from '@tanstack/react-virtual';
const TabList: React.FC = () => {
const [tabs] = useAtom(savedTabsAtom);
const rowVirtualizer = useVirtualizer({
count: tabs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
});
// Render only visible items
};
Conclusion
Implementing jotai chrome extension state management with atomic state provides a clean, maintainable architecture for Chrome extensions of any complexity. The atomic model naturally maps to the compartmentalized nature of extension components—popups, background scripts, content scripts, and options pages all benefit from independent, composable state atoms.
By using atomic state extension patterns with Jotai, you get:
- Automatic persistence via
atomWithStorage - Minimal bundle size (~3KB)
- Excellent TypeScript support
- Simple cross-context state sharing
- Efficient re-renders with atomic subscriptions
Whether you are building a simple jotai popup state manager or a complex extension with multiple contexts, Jotai provides the primitives you need to build robust, maintainable state management.
Start with simple atoms and progressively add complexity as your extension grows. The atomic model scales naturally, and you will never need to refactor your entire state layer as your application evolves.