How the Redux Subscribe/Unsubscribe Mechanism Works: A Deep Dive into Store Listeners

Redux implements a copy-on-write subscription pattern where listeners are stored in a Map called nextListeners, and each dispatch operation iterates over an immutable snapshot to prevent mutation errors during notification cycles.

The subscribe/unsubscribe mechanism in Redux forms the backbone of reactive state management in the reduxjs/redux repository. When you call store.subscribe(listener), the store registers your callback to be executed after every successful dispatch, while the returned unsubscribe function provides a safe cleanup mechanism that respects the store's internal dispatching state.

The Core Architecture of Redux's Subscription System

Redux maintains listener state using two critical data structures in src/createStore.ts: currentListeners and nextListeners. Understanding how these interact explains why Redux can safely handle subscriptions and unsubscriptions even while notifying listeners.

The nextListeners Map and Listener IDs

When you subscribe to the store, Redux assigns your callback a unique numeric identifier (listenerId) and stores it in a Map named nextListeners. This design choice ensures O(1) insertion and deletion performance while maintaining insertion order for deterministic notification sequences.

The listener registration occurs in the subscribe function implementation (lines 211-252 in src/createStore.ts), where the store validates your input before mutating the listener collection.

Copy-on-Write Safety with ensureCanMutateNextListeners

Redux implements a defensive copy-on-write pattern through the ensureCanMutateNextListeners() helper function. Before adding or removing any listener, Redux checks if nextListeners currently references the same object as currentListeners. If they match, Redux creates a shallow copy of the map to ensure that any active dispatch iteration (which uses currentListeners) remains unaffected by structural changes.

This mechanism prevents the "concurrent modification" bugs that would occur if a listener tried to subscribe or unsubscribe another listener during the notification phase.

How store.subscribe() Registers Listeners

The subscribe method in src/createStore.ts follows a strict validation and registration protocol to maintain store integrity.

First, Redux performs safety checks. It throws an error if the provided listener is not a function, ensuring type safety at runtime. It also verifies that isDispatching is false, preventing subscription attempts during active reducer execution—a guard that protects against inconsistent state observations.

Once validated, Redux invokes ensureCanMutateNextListeners() to obtain a safe copy of the listener map, then registers the new callback with a unique ID. The method returns an unsubscribe closure that captures the listenerId and the store's internal state, allowing for later removal without exposing internal implementation details.

How the Unsubscribe Function Removes Listeners

When you invoke the function returned by store.subscribe(), you trigger the unsubscription logic defined in the closure created during subscription.

The unsubscribe function first validates that the listener is still registered (checking if the listenerId exists in nextListeners) and that no dispatch is currently in progress. Like the subscription process, it refuses to mutate the listener list during active dispatching to prevent iteration errors.

After passing these guards, the function calls ensureCanMutateNextListeners() to create a safe copy of the map, then deletes the listener entry using its stored ID. It also clears the currentListeners reference, forcing the next dispatch to rebuild a fresh snapshot from the updated nextListeners map.

Dispatching with Listener Snapshots

The dispatch mechanism in Redux relies on the snapshot pattern to ensure consistent notification behavior. When store.dispatch(action) executes, it captures the current state of listeners using the assignment:

const listeners = (currentListeners = nextListeners);

This line atomically updates currentListeners to reference the current nextListeners map, then assigns that reference to a local listeners variable for iteration. Because currentListeners now points to this specific snapshot, any subsequent subscribe or unsubscribe operations will trigger ensureCanMutateNextListeners() to clone the map, leaving the active listeners reference untouched.

The dispatch process then iterates through this snapshot:

listeners.forEach(listener => listener());

This approach guarantees that listeners added during the dispatch will not be called in the current cycle, and listeners removed during the dispatch will still be called (since they existed in the snapshot), preventing undefined behavior or skipped notifications.

Practical Code Examples

Basic Subscription and Unsubscription

The following example demonstrates the fundamental pattern for reacting to state changes in a Redux application:

import { createStore } from 'redux';
import rootReducer from './reducers';

const store = createStore(rootReducer);

// Register a listener that logs state changes
const unsubscribe = store.subscribe(() => {
  console.log('State updated:', store.getState());
});

// Dispatch actions - listener will fire after each
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'ADD_TODO', text: 'Learn Redux' });

// Clean up subscription when component unmounts or logic completes
unsubscribe();

Selective State Change Listening

Since Redux notifies all listeners on every dispatch regardless of which state slice changed, you typically implement selective reactions by comparing previous and current values:

function selectCount(state) {
  return state.counter;
}

let previousCount = selectCount(store.getState());

const unsubscribe = store.subscribe(() => {
  const currentCount = selectCount(store.getState());
  
  if (previousCount !== currentCount) {
    console.log(`Counter changed: ${previousCount} → ${currentCount}`);
    previousCount = currentCount;
  }
});

Safety Violation: Subscribing During Dispatch

Attempting to subscribe while a reducer is executing throws an error, as shown in this anti-pattern:

function badReducer(state = {}, action) {
  if (action.type === 'ATTEMPT_SUBSCRIBE') {
    // ERROR: Cannot subscribe while dispatching
    store.subscribe(() => console.log('This will throw'));
  }
  return state;
}

const store = createStore(badReducer);

// This dispatch will throw because the reducer tries to subscribe
store.dispatch({ type: 'ATTEMPT_SUBSCRIBE' });

Summary

  • Redux maintains listeners in a Map called nextListeners and assigns each a unique numeric ID for O(1) operations.
  • The copy-on-write pattern via ensureCanMutateNextListeners() creates shallow copies before mutations, ensuring that active dispatch iterations remain unaffected by subscription changes.
  • The subscribe() method in src/createStore.ts validates that the listener is a function and that no dispatch is active before registration.
  • The returned unsubscribe function captures the listener ID and safely removes the callback by checking dispatch state and cloning the listener map before deletion.
  • During dispatch(), Redux captures a snapshot of nextListeners into currentListeners, iterating over this immutable reference to prevent concurrent modification errors while allowing listeners to subscribe/unsubscribe others during notification.

Frequently Asked Questions

What happens if I try to subscribe while a dispatch is in progress?

Redux throws an error immediately. The subscribe method checks the isDispatching flag and throws if true, preventing subscription attempts during reducer execution. This safety guard ensures that the listener list remains stable while the store is processing an action and notifying existing listeners.

Can I unsubscribe from within a listener callback?

Yes, you can safely call the unsubscribe function from within a listener. Because Redux uses the copy-on-write snapshot pattern, the current dispatch iteration iterates over currentListeners (the snapshot taken at dispatch start). Your unsubscribe call will clone nextListeners and remove your listener from the map, but this affects only future dispatches, not the current notification cycle.

Why does Redux use a Map instead of an array for listeners?

Redux uses a Map with numeric keys (listener IDs) to achieve O(1) time complexity for both subscriptions and unsubscriptions. Arrays would require O(n) operations for deletion or searching. The Map also preserves insertion order, ensuring that listeners are notified in the sequence they were added, which provides deterministic behavior for application side effects.

Where is the subscribe logic implemented in the Redux source code?

The core implementation resides in src/createStore.ts (specifically lines 211-252 in the current master branch). This file contains the subscribe function, the ensureCanMutateNextListeners helper, and the unsubscribe closure logic. Type definitions for the Store.subscribe method and the Unsubscribe return type are located in src/types/store.ts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →