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
BehaviorSubjector 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 withnewState.patchState(partialState): MergespartialStateinto 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
nullorundefined). - 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
ComponentStoreservice 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
distinctUntilChangedinternally 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 orprovidedIn: 'root'with a custom provider setup, can be leveraged. - Think about how to manage the lifecycle of the
BehaviorSubjectwithin 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
createInstancemethod 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.