Skip to content

Instantly share code, notes, and snippets.

@SirSerje
Last active October 11, 2024 21:11
Show Gist options
  • Save SirSerje/ce68f3ddb4c56bd12690cb97e2086b6d to your computer and use it in GitHub Desktop.
Save SirSerje/ce68f3ddb4c56bd12690cb97e2086b6d to your computer and use it in GitHub Desktop.

I apologize for the misunderstanding earlier. Let’s focus on diagnosing why your custom state management solution is running slower than NGXS, despite appearing to have a simpler implementation. We’ll explore potential bottlenecks in your code, compare them with NGXS’s optimizations, and provide actionable recommendations to enhance performance.

  1. Potential Performance Bottlenecks in Your Custom Store

a. Usage of Immer’s produce Function

•	Issue: While Immer simplifies immutable state updates, it introduces overhead by creating proxies and performing structural sharing. This can become a performance bottleneck, especially with frequent or large state updates.
•	Impact: Each call to set involves produce, which can slow down state mutations, particularly in applications with complex or large state trees.

b. Local Storage Operations on Every State Update

•	Issue: The set method calls saveToLocalStorage after every state mutation. LocalStorage operations are synchronous and can block the main thread, leading to noticeable performance degradation, especially if the state is sizable or updates are frequent.
•	Impact: Frequent reads and writes to localStorage can significantly slow down your application, making UI interactions feel laggy.

c. Updating All Signals on Every State Change

•	Issue: In your set method, after producing the nextState, you iterate through all keys to update signals only if their values have changed:

for (const key in nextState) { if (Object.prototype.hasOwnProperty.call(nextState, key) && nextState[key] !== currentState[key]) { (this._state[key] as any).set(nextState[key]); } }

•	Comparison Overhead: The !== comparison can be expensive for complex objects or large state slices.
•	Signal Updates: Even with conditional updates, triggering multiple Signal.set calls can introduce performance overhead.

•	Impact: This approach can lead to unnecessary computations and signal updates, especially when multiple parts of the state are interdependent or frequently changing.

d. Double Invocation in getALot Method

•	Issue: The getALot method uses a double invocation:

public getALot() { return this.get(state => { return state.arr })()() }

•	The first () invokes the Signal returned by get.
•	The second () invokes the result of the first invocation.

This can introduce unnecessary function calls and potential re-computations.

•	Impact: While not a major bottleneck, redundant function calls can accumulate, especially if getALot is called frequently.
  1. How NGXS Optimizes Performance

NGXS is a mature state management library with several built-in optimizations:

•	Selective State Updates: NGXS uses decorators and selectors to ensure only relevant parts of the state trigger change detection. This minimizes unnecessary computations.
•	Efficient Change Detection: Leveraging RxJS Observables and Angular’s change detection strategy, NGXS efficiently manages state changes without blocking the main thread.
•	Asynchronous Actions: NGXS handles asynchronous operations gracefully, ensuring that state mutations don’t interfere with UI responsiveness.
•	Middleware and Plugins: Optimizations like memoization and lazy loading of state slices help in reducing performance overhead.
•	Optimized Serialization: NGXS provides mechanisms to optimize state serialization and deserialization, avoiding frequent and heavy operations on every state change.
  1. Recommendations to Improve Your Custom Store’s Performance

To align your custom store’s performance with or surpass NGXS’s, consider the following optimizations:

a. Minimize or Optimize Immer Usage

•	Selective Immer Usage: Instead of using produce for every state update, consider mutating only the necessary parts of the state manually, especially for simple or infrequent updates.
•	Batch Updates: Group multiple state changes into a single produce call to reduce the overhead of multiple proxy creations.
•	Alternative Libraries: Explore lighter alternatives to Immer or implement custom immutable update utilities tailored to your application’s needs.

b. Optimize Local Storage Interactions

•	Debounce State Persistence:
•	Implementation: Instead of saving to localStorage on every set, debounce the saveToLocalStorage calls. For example, wait for a short period (e.g., 300ms) after the last state change before persisting.
•	Benefit: Reduces the number of synchronous localStorage writes, minimizing main thread blocking.

private saveToLocalStorageDebounced = debounce(() => { this.saveToLocalStorage(); }, 300);

protected set(updater: (draft: T) => void): void { const currentState = this.getCurrentState(); const nextState = produce(currentState, updater);

for (const key in nextState) {
    if (Object.prototype.hasOwnProperty.call(nextState, key) && nextState[key] !== currentState[key]) {
        (this._state[key] as any).set(nextState[key]);
    }
}

this.saveToLocalStorageDebounced();

}

(Ensure you import a debounce utility, such as from lodash.)

•	Asynchronous Storage:
•	Implementation: Move localStorage operations to a Web Worker or use IndexedDB with asynchronous APIs to prevent blocking the main thread.
•	Benefit: Keeps the UI responsive during state persistence.

c. Refine Signal Update Strategy

•	Selective Signal Updates:
•	Implementation: Instead of iterating through all state keys, track which specific parts of the state have changed and update only those signals.
•	Benefit: Reduces the number of unnecessary Signal.set calls.
•	Immutable vs. Mutable Signals:
•	Alternative Approach: Consider structuring your signals to represent more granular slices of state, reducing the need to iterate and compare entire state objects.

d. Simplify or Correct getALot Method

•	Remove Redundant Invocations:
•	Implementation: Adjust the getALot method to avoid double function calls. For example:

public getALot() { return this.get(state => state.arr)(); }

•	Benefit: Reduces unnecessary function calls, enhancing performance.

e. Profile and Identify Specific Bottlenecks

•	Use Performance Profiling Tools:
•	Implementation: Utilize browser developer tools to profile your application. Identify which parts of the set and get methods consume the most time.
•	Benefit: Provides concrete data on where optimizations will have the most impact.
•	Measure Impact of Optimizations:
•	Implementation: After each optimization, benchmark the performance to ensure that changes are effective.
•	Benefit: Ensures that optimizations lead to tangible performance improvements.

f. Consider Memoization for Selectors

•	Implementation: Implement memoization for your selector functions to prevent unnecessary recalculations when state slices haven’t changed.

import { memo } from 'lodash';

protected get(selector: (state: StateSignals) => R): Signal { const memoizedSelector = memo(selector); return computed(() => memoizedSelector(this._state)); }

•	Benefit: Reduces computation overhead for selectors, especially if they perform complex operations.

g. Optimize State Structure

•	Flatten State Trees:
•	Implementation: Structure your state to be as flat as possible, minimizing nested objects. This can make state comparisons and updates more efficient.
•	Benefit: Enhances performance of both state mutations and signal updates.
•	Normalize Data:
•	Implementation: Similar to normalization in databases, store data in a normalized form to reduce redundancy and simplify updates.
•	Benefit: Streamlines state management and reduces the complexity of updates.
  1. Example Optimized BaseStore Implementation

Here’s an optimized version of your BaseStore incorporating some of the recommendations:

import { computed, Signal, signal } from '@angular/core'; import { produce } from 'immer'; import { debounce } from 'lodash'; // Ensure lodash is installed

type StateSignals = { [K in keyof T]: Signal<T[K]> };

const STORAGE_PREFIX = 'nf_store';

export abstract class BaseStore {

private _state: StateSignals<T> = {} as StateSignals<T>;
public get state(): StateSignals<T> {
    return this._state;
}

private _storageKey: string | undefined;
private get storageKeyPrefix(): string | undefined {
    return this._storageKey ? `${STORAGE_PREFIX}_${this._storageKey}` : undefined;
}

// Debounced save to localStorage to prevent frequent writes
private saveToLocalStorageDebounced = debounce(() => {
    this.saveToLocalStorage();
}, 300);

constructor(initialState: T, storageKey?: string) {
    this._storageKey = storageKey;

    // Load state from local storage if storage key is provided
    if (this.storageKeyPrefix) {
        const persistedState = this.loadFromLocalStorage();
        if (persistedState) {
            initialState = { ...initialState, ...persistedState };
        }
    }

    // Create a signal for each property in the initial state
    for (const key in initialState) {
        if (Object.prototype.hasOwnProperty.call(initialState, key)) {
            this._state[key] = signal(initialState[key]);
        }
    }
}

// Method to get the current state as an object
private getCurrentState(): T {
    const currentState: Partial<T> = {};
    for (const key in this._state) {
        if (Object.prototype.hasOwnProperty.call(this._state, key)) {
            currentState[key] = this._state[key]();
        }
    }
    return currentState as T;
}

// Method to create a selector with memoization
protected get<R>(selector: (state: StateSignals<T>) => R): Signal<R> {
    const memoizedSelector = memo(selector);
    return computed(() => memoizedSelector(this._state));
}

// Method to update the state
protected set(updater: (draft: T) => void): void {
    const currentState = this.getCurrentState();
    const nextState = produce(currentState, updater);

    // Track changed keys to update signals selectively
    const changedKeys: (keyof T)[] = [];

    for (const key in nextState) {
        if (Object.prototype.hasOwnProperty.call(nextState, key)) {
            if (nextState[key] !== currentState[key]) {
                changedKeys.push(key as keyof T);
            }
        }
    }

    // Update only changed signals
    changedKeys.forEach(key => {
        this._state[key].set(nextState[key]);
    });

    // Debounced save to prevent frequent writes
    this.saveToLocalStorageDebounced();
}

// Method to load cached state from local storage
private loadFromLocalStorage(): Partial<T> | null {
    const cachedData = this.storageKeyPrefix ? localStorage.getItem(this.storageKeyPrefix) : null;
    return cachedData ? JSON.parse(cachedData) : null;
}

// Method to save state to local storage
private saveToLocalStorage(): void {
    if (this.storageKeyPrefix) {
        const currentState = this.getCurrentState();
        try {
            localStorage.setItem(this.storageKeyPrefix, JSON.stringify(currentState));
        } catch (e) {
            console.error('Failed to save state to localStorage:', e);
        }
    }
}

// Method to clear cached state from local storage
public clearCache(): void {
    if (this.storageKeyPrefix) {
        localStorage.removeItem(this.storageKeyPrefix);
    }
}

}

Key Optimizations Implemented:

1.	Debounced Local Storage Saves: Reduces the frequency of localStorage.setItem calls, preventing main thread blocking during rapid state updates.
2.	Selective Signal Updates: Only updates signals that have actually changed, minimizing unnecessary Signal.set invocations.
3.	Memoized Selectors: Prevents redundant computations in selectors by caching results based on input dependencies.
4.	Error Handling: Adds try-catch around localStorage operations to gracefully handle potential errors.
5.	Optimized getALot Method: Ensure that selector invocations are streamlined without redundant calls.
  1. Additional Considerations

a. Avoid Heavy Computations in Selectors

•	Implementation: Ensure that your selector functions are lightweight and avoid performing heavy computations. If necessary, offload complex calculations to Web Workers or perform them asynchronously.

b. Leverage Angular’s Change Detection Strategy

•	Implementation: Utilize OnPush change detection strategy in your components to minimize unnecessary re-renders when state changes occur.

@Component({ // ... changeDetection: ChangeDetectionStrategy.OnPush }) export class YourComponent { /* ... */ }

•	Benefit: Reduces the performance impact of state changes by limiting change detection cycles to relevant components only.

c. Monitor and Profile Regularly

•	Implementation: Use Angular’s built-in profiling tools and browser developer tools to continuously monitor your application’s performance. Identify and address new bottlenecks as your application evolves.

d. Consider Alternative State Management Libraries

•	Evaluation: While optimizing your custom store is feasible, consider evaluating other state management solutions like Akita, NgRx, or even NGXS itself. These libraries come with a suite of performance optimizations and community support that can save development time and ensure scalability.
  1. Summary

Your custom state management solution is a commendable approach leveraging modern Angular features like Signals and Immer. However, certain implementation details—such as frequent localStorage operations, the overhead of Immer, and the strategy for updating signals—can introduce performance bottlenecks, especially when compared to optimized libraries like NGXS.

By implementing the recommended optimizations—debouncing state persistence, refining signal updates, minimizing Immer’s overhead, and profiling your application—you can significantly enhance your store’s performance. Additionally, continuously monitoring and iterating on your implementation will ensure that it scales efficiently as your application grows.

If, after these optimizations, you still encounter performance issues, it might be worthwhile to reconsider using a well-optimized state management library that aligns with your application’s requirements and provides the necessary performance guarantees.

I understand your frustrations with Akita, especially concerning boilerplate and documentation challenges. Let’s delve into Akita’s strengths and weaknesses, address your specific concerns, and explore possible alternatives or solutions to help you decide the best path forward for your Angular application’s state management.

  1. Understanding Akita

Akita is a state management library for Angular applications that emphasizes simplicity and scalability. It leverages RxJS for reactivity and provides a structured approach to managing complex state interactions.

Key Features of Akita:

•	Entity Management: Simplifies handling collections of entities with built-in utilities.
•	Store & Query Separation: Encourages a clear separation between state storage and querying mechanisms.
•	Plugin System: Extensible through plugins for features like persistence, caching, and more.
•	Type Safety: Strong TypeScript support ensures type-safe state management.
•	DevTools Integration: Offers integrations with debugging tools for state inspection.
  1. Addressing Your Concerns with Akita

a. Boilerplate Complexity

Issue: Akita can introduce what feels like “crampy boilerplate,” especially for smaller projects or when setting up complex state interactions.

Analysis:

•	Setup Overhead: Akita requires defining stores, queries, and sometimes entities, which can seem verbose compared to simpler solutions.
•	Scalability vs. Simplicity: While Akita shines in large-scale applications with intricate state needs, this scalability comes at the cost of increased initial setup.

Potential Solutions:

1.	Leverage Akita’s Documentation and Examples:
•	While you mentioned documentation issues, Akita’s official documentation provides comprehensive guides and examples. Additionally, community resources and tutorials can bridge gaps.
2.	Abstract Repetitive Code:
•	Create base classes or utility functions to reduce repetitive boilerplate when setting up stores and queries.

// Example: Base Store import { Store, StoreConfig } from '@datorama/akita';

export interface EntityState { // Define common state properties }

@StoreConfig({ name: 'entity' }) export class EntityStore extends Store { constructor() { super({ /* initial state */ }); } }

3.	Use Angular Schematics:
•	Akita offers schematics that can automate the creation of stores, queries, and other components, reducing manual boilerplate.

ng generate akita:store ng generate akita:query

b. Documentation Quality

Issue: Insufficient or unclear documentation, particularly regarding Angular integration and persistence.

Analysis:

•	Rapid Development: Libraries evolve quickly, sometimes outpacing documentation updates.
•	Niche Features: Advanced topics like custom persistence might not be as thoroughly covered.

Potential Solutions:

1.	Community Support:
•	Engage with Akita’s community through GitHub issues, forums, or platforms like Stack Overflow. Often, community-contributed guides and examples can fill documentation gaps.
2.	Official Plugins:
•	Utilize Akita’s official plugins for persistence, such as akita-ng-entity-service or akita-persist-state, which offer more streamlined integration.

// Example: Persisting State with Akita import { persistState } from '@datorama/akita'; import { EntityStore } from './entity.store';

persistState({ include: ['entityStore'], storage: localStorage, });

3.	Contribute to Documentation:
•	If you find gaps, consider contributing to Akita’s documentation. This not only helps you clarify your understanding but also assists others facing similar challenges.
  1. Comparing Akita with NGXS and Your Custom Solution

Given your concerns with both NGXS and Akita, let’s briefly compare them with your custom solution to provide a holistic view.

Feature/Aspect Custom Solution NGXS Akita Boilerplate Minimal, tailored to needs Moderate, with decorators and actions Higher, especially for large-scale setups Documentation Limited to your implementation Comprehensive but can be overwhelming Adequate but with some gaps Performance Depends on implementation (potentially optimized) Generally good with RxJS optimizations Optimized for entity management and scalability Flexibility Highly flexible, full control Structured but with conventions Structured with emphasis on scalability Persistence Support Manual implementation required Plugins and community packages available Built-in plugins for state persistence Learning Curve Lower, if simple Moderate to high Moderate, especially with entity patterns

Your Custom Solution offers high flexibility and minimal boilerplate, allowing you to optimize performance based on your specific needs. However, it requires manual handling of features like persistence, selectors, and state updates, which libraries like NGXS and Akita handle out-of-the-box (albeit with their own complexities).

  1. Recommendations and Alternatives

a. Stick with Your Custom Solution

Given your dissatisfaction with both NGXS and Akita, refining your custom solution might be the best path forward. Here’s how you can enhance it:

1.	Optimize State Management Logic:
•	As previously discussed, minimize the use of heavy libraries like Immer if they’re causing performance bottlenecks. Use Angular’s built-in Signals more effectively.
2.	Implement Efficient Persistence:
•	Debounce or throttle localStorage writes.
•	Consider using IndexedDB for larger or more complex state persistence needs with asynchronous APIs.
•	Use Web Workers to offload persistence tasks from the main thread.
3.	Enhance Selector Efficiency:
•	Ensure selectors are memoized and lightweight.
•	Avoid redundant computations within selectors.
4.	Modularize State Slices:
•	Break down your state into smaller, manageable slices or modules. This can improve readability and maintainability without introducing full-fledged boilerplate.
5.	Leverage Angular’s Advanced Features:
•	Utilize Angular’s Dependency Injection effectively to provide and manage your custom stores.
•	Implement OnPush change detection in components to optimize rendering.

b. Explore Other State Management Libraries

If you’re open to exploring beyond NGXS and Akita, consider the following alternatives:

1.	NgRx

Pros: • Mature and Widely Adopted: Extensive community support and documentation. • Redux Pattern: Familiarity for developers with Redux experience. • Powerful Ecosystem: Includes tools like NgRx Effects, Entity, and Router Store. Cons: • Steep Learning Curve: Boilerplate-heavy, which might not align with your preference for minimalism. • Verbosity: Requires defining actions, reducers, effects, and selectors, which can feel cumbersome. Suitability: Best for large-scale applications requiring robust state management and middleware capabilities. 2. MobX (via mobx-angular) Pros: • Reactive and Minimal Boilerplate: Leverages observables with less boilerplate compared to Redux-like libraries. • Flexible: Allows mutable state management with reactive updates. Cons: • TypeScript Support: May require additional configuration for optimal TypeScript integration. • Community and Support: Smaller community within the Angular ecosystem compared to NgRx or Akita. Suitability: Suitable for applications where reactivity and minimal boilerplate are prioritized. 3. SimpleRxStore Pros: • Lightweight: Minimalistic approach using RxJS. • Flexible: Easy to integrate and customize. Cons: • Limited Features: Lacks the advanced features of larger libraries like NgRx or Akita. • Community Support: Smaller community and fewer resources. Suitability: Ideal for small to medium applications needing basic state management without additional overhead.

c. Hybrid Approach

Combine your custom solution with selective features from established libraries:

1.	Use Your Custom Store with Specific Libraries:
•	Integrate RxJS utilities or specific Akita plugins without adopting the entire library.
•	This allows you to benefit from certain optimizations or features without the associated boilerplate.
2.	Create Abstractions and Utilities:
•	Develop utility functions or services that encapsulate common patterns (e.g., persistence, state snapshots) to reduce boilerplate and enhance reusability.

// Example: Persistence Utility export function persistState(store: BaseStore, debounceTime: number = 300) { const debouncedSave = debounce(() => store.saveToLocalStorage(), debounceTime); store.stateChanges.subscribe(() => { debouncedSave(); }); }

  1. Enhancing Your Custom Solution: Practical Steps

To ensure your custom store remains performant and maintainable, consider the following actionable steps:

a. Optimize State Updates

•	Immutable Updates Without Immer:
•	Implement manual immutable updates for simple state changes to reduce overhead.

protected set(updater: (state: T) => T): void { const currentState = this.getCurrentState(); const nextState = updater(currentState);

// Identify and update only changed properties Object.keys(nextState).forEach(key => { if (nextState[key] !== currentState[key]) { this._state[key].set(nextState[key]); } });

this.saveToLocalStorageDebounced(); }

b. Efficient Persistence Mechanisms

•	Asynchronous Persistence with IndexedDB:
•	Use libraries like Dexie.js to handle asynchronous storage, preventing main thread blocking.

import Dexie from 'dexie';

class MyStore extends BaseStore { private db: Dexie;

constructor(initialState: T, storageKey?: string) { super(initialState, storageKey); this.db = new Dexie('MyAppDB'); this.db.version(1).stores({ state: 'key' }); this.loadFromIndexedDB(); }

private async loadFromIndexedDB() { const persistedState = await this.db.table('state').get(this.storageKeyPrefix); if (persistedState) { this.set(state => ({ ...state, ...persistedState })); } }

private async saveToIndexedDB() { if (this.storageKeyPrefix) { await this.db.table('state').put(this.getCurrentState(), this.storageKeyPrefix); } } }

c. Modularize State Slices

•	Divide and Conquer:
•	Break down your state into multiple smaller stores or modules, each responsible for a specific domain or feature. This enhances maintainability and reduces complexity.

// Example: UserStore export class UserStore extends BaseStore { constructor() { super(initialUserState, 'user-store'); }

// User-specific methods }

// Example: ProductStore export class ProductStore extends BaseStore { constructor() { super(initialProductState, 'product-store'); }

// Product-specific methods }

d. Implement Advanced Selectors and Memoization

•	Memoized Selectors:
•	Use memoization techniques to prevent unnecessary recalculations in selectors.

import { memoize } from 'lodash';

protected get(selector: (state: StateSignals) => R): Signal { const memoizedSelector = memoize(selector); return computed(() => memoizedSelector(this._state)); }

e. Utilize Angular’s Change Detection Strategies

•	OnPush Strategy:
•	Ensure your components use the OnPush change detection strategy to minimize unnecessary checks and re-renders.

@Component({ // ... changeDetection: ChangeDetectionStrategy.OnPush }) export class YourComponent { /* ... */ }

f. Continuous Performance Profiling

•	Identify and Address Bottlenecks:
•	Regularly use browser performance profiling tools to monitor your application’s performance.
•	Focus on optimizing parts of the state management logic that consume the most resources.
  1. Final Thoughts

Choosing the right state management approach is pivotal for your application’s scalability, performance, and maintainability. While libraries like NGXS and Akita offer powerful features and community support, they come with their own sets of complexities and overheads.

Given your preference for minimalism and performance, continuing with and refining your custom solution seems aligned with your goals. By implementing the optimizations and strategies discussed above, you can achieve a lightweight, efficient, and maintainable state management system tailored precisely to your application’s needs.

However, it’s also beneficial to remain open to revisiting established libraries as your application evolves. They often incorporate optimizations and best practices that can save development time and enhance robustness, especially for complex or large-scale applications.

Ultimately, the best approach is one that balances performance, maintainability, scalability, and developer experience, fitting seamlessly into your project’s unique requirements and your team’s workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment