Skip to main content

API: store.subscribe()

The store.subscribe() method allows you to listen for changes in a specific part of your SoulState store. This is the low-level mechanism that powers the useStore React hook, but it can also be used directly for non-React integrations, logging, or advanced side effects.

Summary

store.subscribe() registers a listener function that will be called whenever the selected slice of state changes. It returns an unsubscribe function to clean up the listener.

Function Signature

interface Store<T> {
subscribe: <S>(
selector: (state: T) => S,
listener: (selectedState: S, prevSelectedState: S) => void,
options?: { equalityFn?: (a: S, b: S) => boolean }
) => () => void;
}

Parameters

  1. selector: (state: T) => S:

    • A function that receives the entire store state (state: T) and returns a specific slice of that state (S).
    • The listener will only be invoked if the value returned by this selector changes.
  2. listener: (selectedState: S, prevSelectedState: S) => void:

    • A callback function that is executed when the selectedState (as determined by the selector and equalityFn) changes.
    • It receives two arguments: selectedState (the new value) and prevSelectedState (the previous value).
  3. options?: { equalityFn?: (a: S, b: S) => boolean }:

    • An optional object to configure the subscription.
    • equalityFn?: (a: S, b: S) => boolean: A function used to compare the selectedState with prevSelectedState. If this function returns true, the listener will not be called.
    • Default: Object.is (strict reference equality).
    • Commonly used: shallow for shallow object comparison.

Return Value

  • () => void: An unsubscribe function. Calling this function will remove the registered listener from the store, preventing memory leaks.

Usage Examples

Basic Subscription

Listen to changes in a single primitive value.

import { counterStore } from '../stores/counterStore';

const unsubscribe = counterStore.subscribe(
(state) => state.count, // Selector: selects the 'count' property
(newCount, prevCount) => { // Listener: logs the change
console.log('Count changed from ' + prevCount + ' to ' + newCount);
}
);

// Trigger some changes
counterStore.set((state) => ({ count: state.count + 1 })); // Logs: Count changed from 0 to 1

// Clean up when done
unsubscribe();

Advanced Subscription with Custom Equality

Use a custom equality function to control when the listener is called.

import { todoStore } from '../stores/todoStore';

const unsubscribe = todoStore.subscribe(
(state) => state.todos.length, // Selector: get number of todos
(newLength, prevLength) => {
console.log('Todo count changed from ' + prevLength + ' to ' + newLength);
},
{
equalityFn: (a, b) => Math.abs(a - b) < 2 // Only notify if change is >= 2
}
);

Subscribing to Multiple Properties

Use shallow equality when selecting multiple properties.

import { userStore } from '../stores/userStore';
import { shallow } from 'soulstate/utils';

const unsubscribe = userStore.subscribe(
(state) => ({ name: state.name, email: state.email }), // Selector: returns new object
(newUserInfo, prevUserInfo) => {
console.log('User info changed:', newUserInfo);
},
{
equalityFn: shallow // Use shallow comparison for object
}
);

Real-time Analytics Example

import { analyticsStore } from '../stores/analyticsStore';

// Track user behavior in real-time
const analyticsUnsubscribe = analyticsStore.subscribe(
(state) => ({ pageViews: state.pageViews, activeUsers: state.activeUsers }),
(metrics, prevMetrics) => {
// Send to analytics service only when metrics change
if (metrics.pageViews !== prevMetrics.pageViews) {
analytics.track('page_view', { count: metrics.pageViews });
}
},
{ equalityFn: shallow }
);

// Later, when analytics are no longer needed
analyticsUnsubscribe();

Performance Characteristics

O(1) Subscription Management

SoulState uses a doubly linked list for subscription management, providing:

  • O(1) subscription addition
  • O(1) subscription removal
  • O(n) notification (where n = number of subscriptions)
// Simplified linked list structure from src/core/subscriptions.ts
interface SubscriptionNode<T, S> {
selector: (state: T) => S;
listener: (selectedState: S, prevSelectedState: S) => void;
equalityFn: (a: S, b: S) => boolean;
lastState: S;
prev: SubscriptionNode<T, any> | null;
next: SubscriptionNode<T, any> | null;
}

Microtask Batching

All subscription notifications are batched using queueMicrotask:

// Multiple updates → single notification batch
counterStore.set({ count: 1 }); // 🚫 No immediate notification
counterStore.set({ count: 2 }); // 🚫 No immediate notification
counterStore.set({ count: 3 }); // 🚫 No immediate notification
// ✅ All three updates notified in single microtask

Anti-patterns

1. Forgetting to Unsubscribe

// ❌ Memory leak: subscription never cleaned up
const startAnalytics = () => {
store.subscribe(selector, listener); // Never unsubscribed
};

// ✅ Always clean up subscriptions
const startAnalytics = () => {
const unsubscribe = store.subscribe(selector, listener);
return unsubscribe; // Return for cleanup
};

2. Expensive Selectors in Tight Loops

// ❌ Expensive operation on every state change
store.subscribe(
(state) => state.data.filter(item => item.active).sort((a, b) => b.id - a.id),
listener // Called on every state change with expensive computation
);

// ✅ Memoize expensive selectors
const expensiveSelector = (state) => {
// Memoization logic here
return computedData;
};

Common Use Cases

1. Logging and Debugging

// Development logging
if (process.env.NODE_ENV === 'development') {
store.subscribe(
state => state,
(state, prevState) => {
console.log('State changed:', { from: prevState, to: state });
},
{ equalityFn: shallow }
);
}

2. Persistence Layer

// Auto-save to localStorage
const persistenceUnsubscribe = store.subscribe(
state => state.userPreferences,
(prefs) => {
localStorage.setItem('userPrefs', JSON.stringify(prefs));
},
{ equalityFn: shallow }
);

3. Integration with Non-React Code

// Update legacy jQuery components
const jqueryUnsubscribe = store.subscribe(
state => state.theme,
(theme) => {
$('#app').attr('data-theme', theme);
}
);
⚠️

Memory Management

Always call the returned unsubscribe function when you no longer need the subscription. This prevents memory leaks, especially in long-running applications.

💡

React Alternative

For React components, prefer the useStore hook instead of manual subscriptions. The hook automatically handles subscription cleanup when the component unmounts.

ℹ️

Implementation Detail

The subscription system uses a doubly linked list implementation in src/core/subscriptions.ts for optimal O(1) add/remove operations.