Skip to main content

Subscriptions

SoulState's performance is built on its precise and efficient subscription model. When you use the useStore hook, you are creating a subscription that is highly optimized to prevent unnecessary re-renders.

The Subscription Model

At its core, a subscription consists of three parts:

  1. A Selector Function: A function you provide that extracts a specific slice of data from the state.
  2. A Listener Function: A callback (managed internally by useStore) that React uses to trigger a re-render.
  3. An Equality Function: A function that compares the previous selected state with the new one to determine if a change has occurred.

A listener is only called if !equalityFn(newSelectedState, oldSelectedState).

// Example: useStore(store, selector, equalityFn)

// 1. The selector extracts the 'user' object
const selector = state => state.user;

// 2. The listener is React's internal 'onStoreChange' function

// 3. The equality function defaults to Object.is
const equalityFn = (a, b) => Object.is(a, b);

// This component subscribes to the 'user' object.
// It will only re-render if the reference to state.user changes.
const user = useStore(useAppStore, state => state.user);

Default Equality Check: Object.is

By default, SoulState uses Object.is for equality checking. This is a strict reference equality check, similar to ===.

This means a subscription will trigger an update if:

  • The selected value is a primitive (string, number, boolean) and its value changes.
  • The selected value is an object or array, and its reference changes (i.e., a new object/array is created).
// Primitive values
Object.is(1, 1); // true (no update)
Object.is(1, 2); // false (update!)

// Object references
const user = { name: 'John' };
const sameUser = user;
const newUser = { name: 'John' };

Object.is(user, sameUser); // true (no update)
Object.is(user, newUser); // false (update!)
ℹ️

Why Reference Equality is Fast

Reference checks are extremely fast because they only involve comparing memory addresses. This is why SoulState encourages an immutable update pattern—creating new objects for changes makes the change detection trivial and efficient.

Shallow Equality Checking

Often, you need to select multiple values from the store at once. If you create a new object in your selector, the default Object.is check will always fail, causing a re-render on every state change.

// ⚠️ ANTI-PATTERN: This will re-render on EVERY state change!
// Why? Because a new object { user, posts } is created on every run.
const { user, posts } = useStore(useAppStore, state => ({
user: state.user,
posts: state.posts
}));

To solve this, SoulState provides a shallow equality function. You can use it by importing shallow from soulstate/utils.

The shallow function performs a shallow comparison of the keys and values of two objects. It's the perfect tool for selectors that return a new object.

import { useStore } from 'soulstate/react';
import { shallow } from 'soulstate/utils';

// ✅ CORRECT: This will only re-render if 'user' or 'posts' change.
const { user, posts } = useStore(
useAppStore,
state => ({ user: state.user, posts: state.posts }),
shallow // Use the shallow equality checker
);

Custom Equality Functions

For more complex scenarios, such as deep comparisons or ignoring certain fields, you can provide your own custom equality function as the third argument to useStore.

import { useStore } from 'soulstate/react';

// Custom equality function that compares by id only
const compareById = (a, b) => a.id === b.id;

const user = useStore(
useAppStore,
state => state.user,
compareById
);

// Or use a deep comparison library
import { equal } from 'fast-deep-equal';

const settings = useStore(
useAppStore,
state => state.settings,
equal
);
🔥

Performance Warning

Deep equality checks can be expensive. Use them with caution and only when necessary. For most cases, Object.is or shallow are sufficient and more performant.

Under the Hood: The Subscription Graph

SoulState manages all subscriptions in a doubly linked list. This data structure is a key reason for its high performance, offering O(1) time complexity for both adding and removing subscriptions.

  • When a component mounts: A new node is added to the end of the list (O(1)).
  • When a component unmounts: The node is removed by updating its neighbors' pointers (O(1)).

This is significantly more efficient than using an array, where removing an item can be an O(n) operation, especially in large, dynamic applications.

// Simplified representation of the linked list structure
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;
}
ℹ️

Implementation Detail

The actual subscription implementation can be found in src/core/subscriptions.ts, which uses a doubly linked list for O(1) add/remove operations and efficient iteration.

Microtask Batching

SoulState batches multiple state updates using queueMicrotask, ensuring that subscribers are notified only once per event loop tick, even for multiple consecutive updates.

// In the store implementation:
const scheduleNotification = () => {
if (!isNotificationScheduled) {
isNotificationScheduled = true;
queueMicrotask(notifySubscribers); // Batched!
}
};

// Multiple updates in one function
const handleMultipleUpdates = () => {
store.set({ count: 1 }); // 🚫 No immediate notification
store.set({ user: 'John' }); // 🚫 No immediate notification
store.set({ active: true }); // 🚫 No immediate notification
// ✅ All three updates batched into ONE notification
};

// This results in only one re-render for all three changes
💡

Performance Benefit

Microtask batching means that rapid, consecutive state updates (like in animations or rapid user input) won't cause excessive re-renders. All updates within the same event loop are coalesced into a single notification.

Subscription Lifecycle

Understanding the subscription lifecycle helps debug performance issues:

  1. Mount: Component calls useStore → New subscription node created
  2. Update: State changes → Selector runs → Equality check → Re-render if changed
  3. Unmount: Cleanup function runs → Subscription node removed
function UserProfile() {
// 1. On mount: subscription created and added to linked list
const user = useStore(store, state => state.user);

// 2. On state change: selector runs, equality check happens
// 3. On unmount: subscription automatically removed from linked list

return <div>{user.name}</div>;
}

Best Practices

✅ Do:

  • Use precise selectors to subscribe to minimal data
  • Leverage shallow for object selections
  • Keep equality functions simple and fast
  • Use multiple useStore calls for independent data

❌ Don't:

  • Select entire state objects unnecessarily
  • Use expensive equality functions without need
  • Create new objects in selectors without shallow
  • Forget about the performance benefits of the linked list

Key Takeaway

SoulState's subscription system gives you surgical control over re-renders. By combining precise selectors with efficient equality checks and O(1) linked list operations, you get optimal performance by default.