Hone logo
Hone
Problems

Angular Component Store for Dynamic Data Management

This challenge focuses on building a robust and scalable component store in Angular. A component store is crucial for managing complex application state efficiently, especially when multiple components need to access and modify the same data. This exercise will test your understanding of Angular's dependency injection, RxJS for reactive programming, and state management patterns.

Problem Description

Your task is to create a reusable "ComponentStore" service in Angular that allows components to manage their own local state without relying on parent components or external libraries like NgRx. This store should be capable of:

  • Initializing state: Components should be able to define an initial state upon store creation.
  • Accessing state: Components should be able to subscribe to state changes and retrieve the current state.
  • Updating state: Components should be able to update specific parts of the state or the entire state.
  • Handling asynchronous operations: The store should facilitate managing asynchronous data fetching and updates.
  • Scoped state: Each instance of the store should manage its own isolated state, meaning two components using the same store class should have independent data.

Key Requirements:

  • The store should be implemented as an Angular service.
  • It should leverage RxJS BehaviorSubject or a similar mechanism to manage state reactively.
  • Provide methods for:
    • getState(): Returns an Observable of the current state.
    • setState(newState): Replaces the entire current state with newState.
    • patchState(partialState): Merges partialState into the current state.
    • updateState(updaterFn): Accepts a function that receives the current state and returns the new state.
  • The store should be generic, allowing it to store any type of data.
  • It should be designed to be easily testable.

Expected Behavior:

When a component injects the ComponentStore, it should get its own instance of the store. Changes made to this instance by the component should only affect that specific component's view of the state. Other components injecting the same store class should not be affected by these changes.

Edge Cases to Consider:

  • Initial state is not provided during instantiation (e.g., default to null or undefined).
  • Attempting to update or patch state when the store hasn't been initialized.
  • Handling complex state objects with nested properties.

Examples

Let's assume we have a UserProfile interface:

interface UserProfile {
  name: string;
  age: number;
  email?: string;
}

Example 1: Basic State Management

Component A injects ComponentStore<UserProfile>.

// Component A
@Component({...})
export class ComponentA implements OnInit, OnDestroy {
  private userStore!: ComponentStore<UserProfile>;
  userProfile$!: Observable<UserProfile>;
  private destroy$ = new Subject<void>();

  constructor(@Inject(ComponentStore) @Optional() private parentStore: ComponentStore<any>, // Example of potential parent injection for scoping
              @Inject(ComponentStore) private userStoreFactory: ComponentStore<UserProfile>) {
    // In a real scenario, you'd likely use a factory or provide the store with initial state
    this.userStore = userStoreFactory.createInstance({ name: 'Alice', age: 30 }); // Assuming a factory method
    this.userProfile$ = this.userStore.getState();
  }

  ngOnInit() {
    // Subscribe and update UI...
  }

  updateUserName(newName: string) {
    this.userStore.patchState({ name: newName });
  }

  updateUserAge(newAge: number) {
    this.userStore.updateState(currentState => ({ ...currentState, age: newAge }));
  }

  ngOnDestroy() {
    // Cleanup if necessary, though RxJS subscriptions handle most of it
  }
}

Expected Output/Behavior:

Initially, userProfile$ will emit { name: 'Alice', age: 30 }. After updateUserName('Bob'), userProfile$ will emit { name: 'Bob', age: 30 }. After updateUserAge(31), userProfile$ will emit { name: 'Bob', age: 31 }.

Example 2: Independent Stores

Component B injects ComponentStore<UserProfile> as well.

// Component B
@Component({...})
export class ComponentB implements OnInit {
  private userStore!: ComponentStore<UserProfile>;
  userProfile$!: Observable<UserProfile>;

  constructor(@Inject(ComponentStore) private userStoreFactory: ComponentStore<UserProfile>) {
    this.userStore = userStoreFactory.createInstance({ name: 'Charlie', age: 25 }); // Different initial state
    this.userProfile$ = this.userStore.getState();
  }

  updateUserEmail(email: string) {
    this.userStore.patchState({ email: email });
  }
}

Expected Output/Behavior:

userProfile$ in Component B will initially emit { name: 'Charlie', age: 25 }. If Component A calls updateUserName('David'), the userProfile$ in Component A will update, but the userProfile$ in Component B will remain { name: 'Charlie', age: 25 }.

Example 3: Asynchronous State Update

Component C needs to fetch user data.

// Component C
@Component({...})
export class ComponentC implements OnInit {
  private userStore!: ComponentStore<UserProfile | null>; // Can hold null initially
  userProfile$!: Observable<UserProfile | null>;

  constructor(@Inject(ComponentStore) private userStoreFactory: ComponentStore<UserProfile | null>) {
    this.userStore = userStoreFactory.createInstance(null); // Initialize with null
    this.userProfile$ = this.userStore.getState();
  }

  ngOnInit() {
    this.fetchUserData('user-123');
  }

  fetchUserData(userId: string) {
    this.userStore.setState(null); // Clear previous data while fetching
    // Simulate an API call
    of({ name: 'Diana', age: 28, email: 'diana@example.com' })
      .pipe(
        delay(1000), // Simulate network latency
        tap(data => console.log('Fetched data:', data)),
        takeUntil(this.destroy$) // Assuming destroy$ is managed for cleanup
      )
      .subscribe(userData => {
        this.userStore.setState(userData); // Update state with fetched data
      });
  }

  // ... ngOnDestroy for cleanup
}

Expected Output/Behavior:

Initially, userProfile$ will emit null. After approximately 1 second, userProfile$ will emit { name: 'Diana', age: 28, email: 'diana@example.com' }.

Constraints

  • The ComponentStore service must be written in TypeScript.
  • The solution should not rely on any third-party state management libraries (e.g., NgRx, Akita, Elf).
  • The store should be designed to be tree-shakable if not used.
  • Performance: State updates and subscriptions should be efficient, avoiding unnecessary re-renders. The use of RxJS operators like distinctUntilChanged internally for comparisons is encouraged.

Notes

  • Consider how you will provide the initial state to each instance of your ComponentStore. Angular's dependency injection mechanisms, particularly factories or providedIn: 'root' with a custom provider setup, can be leveraged.
  • Think about how to manage the lifecycle of the BehaviorSubject within the store.
  • For advanced users, consider how to implement middleware or effects for handling side effects. However, this is not a strict requirement for the core challenge.
  • The createInstance method in the examples is a conceptual representation. You'll need to decide on the best Angular DI pattern to achieve this singleton-per-component-instance behavior. One common pattern involves using a factory function that returns a new store instance, or leveraging Angular's hierarchical injectors.
Loading editor...
typescript