Day 180: Managing Shared State in Chrome Extensions

So I went ahead and did the analysis that I talked about in yesterday’s post. This will likely be interesting to no one but me, myself, and I, but since I spent many hours thinking about this, I might as well write it down for posterity.

The question I’m exploring: How should shared state be managed in browser extensions?

Requirements

My one and only requirement is that shared state should be synced across all contexts (background script, popup, content scripts). This is necessary to build features like indicating on the bottom right of the web page when Browserflow starts recording:

The Challenge

The challenge with sharing state in a browser extension is that the different parts of the extension run in different execution contexts. If I set a global variable in the background script, that variable is not available in a content script or in the popup.

Solutions

There are two ways to communicate between different contexts:

  1. Storage

    By attaching listeners, you can receive updates whenever the storage changes and update your local state.

    Note that this requires that all shared state be persisted to storage.

  2. Message passing

    You can send messages to update the state in other contexts.

My Current Solution: useStorageItem

I currently use the storage approach to share state between different contexts. I’ve developed a set of utilities to easily interact with the storage from both a normal script and from React components. All I need to do is define a model with its storage key and the structure of the data, and everything just works!

Example from a background script:

const flowManager = FlowManager.getInstance();
flowManager.setIsRecording(false);

Example from a React component:

const RecordingStatus: React.FC = () => {
  const flowManager = useStorageItem(FlowManager);

  if (!flowManager) {
    // Not yet loaded from storage
    return null;
  }

  return <div>{flowManager.isRecording ? 'Recording!' : 'Not recording'}</div>;
}

I’ve found this to be extremely convenient to work with, and it makes sharing state a breeze.

But there’s one major shortcoming with this approach: While updating and getting data from storage is quite fast, it’s not fast enough to use with controlled components. That is, the delay between updating the storage and receiving the updated item through the listener is high enough that typing at high speeds results in dropped keys.

Here’s what holding a key down should look like using a simple controlled component:

const FlowName: React.FC = () => {
  const [name, setName] = useState('');
  return <input type="text" value={name} onChange={(event) => {
    setName(event.target.value);
  }}></input>
}

Here’s what it actually looks when getting the state directly from storage:

const FlowName: React.FC<FlowNameProps> = ({ flow }) => {
  return <input type="text" value={flow.name} onChange={(event) => {
    flow.setName(event.target.value);
  }}></input>
}

What’s happening here is that asynchronously updating the state causes a delay long enough where the component will often not have its state updated before onChange is called again.

The solution for this is to use local state for controlled components, initialized with the value from storage. This way, updates will occur synchronously since the state is held in memory. And on every update, the new value will be persisted to storage using the useEffect hook:

const FlowName: React.FC<FlowNameProps> = ({ flow }) => {
  // Use local state initialized from storage
	const [name, setName] = useState(flow.name);

  // Update storage when the name changes
  useEffect(() => {
    flow.setName(name); // Saves to storage
  }, [flow, name]);

  return <input type="text" value={name} onChange={(event) => {
    setName(event.target.value);
  }}></input>
}

Proposed solution: Redux

I started considering Redux for state management for two reasons:

  • Eliminate the need to create extra state for controlled components

    If the shared state is held entirely in memory rather than in storage, updates would occur synchronously and I wouldn’t need to create the extra local state for controlled components.

  • Implement Undo History

    Redux’s architecture makes it straightforward to implement undo/redo functionality, which I wanted to add to Browserflow for editing flows.

If I went with Redux and kept the application state entirely in memory, I would have to solve the original challenge again: How do I keep the shared state in sync? How do I make sure that if the Redux store is updated in the popup, then the Redux store in a content script would get the same value?

Enter webext-redux, a set of utilities that solves that problem for browser extensions. In a nutshell, the way that it keeps the Redux stores shared between different contexts is to keep a primary store in the background script and proxy all actions from other contexts to the primary store. It’s a simple and elegant solution.

Unfortunately for me, the fact that actions are proxied to the background script means that state updates are asynchronous. That means that I’ll still need to create extra state for controlled components in order to avoid dropping keystrokes.

The way to keep updates synchronous would be to give each context its own store and use message passing to manually keep them in sync, but that seems quite involved and I have zero interest in building that out.

So at this point, I’m stuck with creating extra state for controlled components regardless of whether I keep my current storage-based syncing mechanism or use webext-redux.

I thought about going with webext-redux anyway because (1) I can avoid the requirement in useStorageItem that all shared state be persisted to storage and (2) using Redux will still help me with implementing undo history.

But there’s an upcoming change in Chrome extensions called Manifest V3 that gave me pause: all background scripts will become ephemeral service workers. In other words, Chrome will no longer allow background scripts to remain persistent.

That poses a problem to webext-redux’s architecture, as it assumes that the background script will remain persistent. If it weren’t, then the primary store held in memory would be periodically wiped out.

There’s an easy solution where you could persist the state into storage so that when the service worker spins up again, it can fetch the last remembered state. But that means my desire to avoid persisting all shared state to storage was misguided and that it might actually be necessary in the future.

So I’m left with the other reason: implementing undo history. Is it worth introducing Redux for that alone?

Here’s how I feel about Redux in general: I’d like to avoid it if I can. While the unidirectional flow of dispatch action → call reducers → update state leads to improved predictability and easier debugging, imposing that structure comes at a cost: it adds indirection.

To be honest, I’d probably get used to it if I started using Redux regularly, but I’m not sure that the extra overhead is worth it right now. Everything’s worked pretty well so far without Redux, and I’m going to take Dan Abramov’s advice: “Don’t use Redux until you have problems with vanilla React.”

Conclusion: Until I find a compelling reason to switch, use vanilla React + useStorageItem instead of Redux + webext-redux

Update: Okay, my friend Shiva, whose opinion I hold highly in all things including the merits of swallowing the Redux pill, thinks I should give Redux a try so I’m going to pair with him and I might just change my mind!