Use when creating or modifying Angular components in MusicTheory - ensures standalone component pattern, proper RxJS subscription management, and integration with MusicTheoryService
All components in MusicTheory use Angular 21's standalone component pattern. This skill ensures consistency with existing architecture and proper integration.
Core principle: Standalone components, centralized state in MusicTheoryService, proper cleanup.
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
import { MusicTheoryService } from '../../services/music-theory.service';
import { MusicTheoryState } from '../../models/music-theory.model';
@Component({
selector: 'app-my-component',
standalone: true,
imports: [CommonModule], // Add other imports as needed
templateUrl: './my-component.component.html',
styleUrls: ['./my-component.component.scss']
})
export class MyComponent implements OnInit, OnDestroy {
// Inputs from parent
@Input() someInput: string = '';
// Outputs to parent
@Output() someEvent = new EventEmitter<void>();
// Destroy subject for cleanup
private destroy$ = new Subject<void>();
// State from service
state: MusicTheoryState | null = null;
constructor(private musicTheoryService: MusicTheoryService) {}
ngOnInit(): void {
// Subscribe to service state
this.musicTheoryService.getState()
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.state = state;
this.onStateChange(state);
});
}
ngOnDestroy(): void {
// CRITICAL: Always clean up subscriptions
this.destroy$.next();
this.destroy$.complete();
}
private onStateChange(state: MusicTheoryState): void {
// React to state changes
}
}
components/
my-component/
my-component.component.ts # Component class
my-component.component.html # Template
my-component.component.scss # Styles (component-scoped)
my-component.component.spec.ts # Tests (optional but recommended)
// Get current state value (one-time)
const currentState = this.musicTheoryService.getState().value;
// Subscribe to state changes (reactive)
this.musicTheoryService.getState()
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
// React to changes
});
// Use service methods - NEVER modify state directly
this.musicTheoryService.setSelectedKey(newKey);
this.musicTheoryService.setSelectedCategory(categoryId);
this.musicTheoryService.setInstrument(instrumentId);
interface MusicTheoryState {
selectedKey: string; // Current root note (C, C#, D, etc.)
selectedCategoryId: string; // Scale/chord category
selectedItemId: string; // Specific scale or chord
instrumentId: string; // guitar, bass, piano
tuningName: string; // Current tuning
stringCount: number; // For stringed instruments
showNashvilleNumbers: boolean;
}
<!-- Use *ngIf for conditional blocks -->
<div *ngIf="state?.instrumentId === 'piano'" class="keyboard-container">
<app-keyboard></app-keyboard>
</div>
<!-- Use @if for new control flow (Angular 17+) -->
@if (state?.instrumentId === 'piano') {
<app-keyboard></app-keyboard>
}
<!-- Always use trackBy for performance -->
<div *ngFor="let note of notes; trackBy: trackByIndex" class="note">
{{ note.name }}
</div>
<!-- In component -->
trackByIndex(index: number): number {
return index;
}
<!-- Or track by unique property -->
trackByNoteId(index: number, note: FretNote): string {
return `${note.fret}-${note.noteValue}`;
}
<!-- Click events -->
<button (click)="onNoteClick(note)">{{ note.name }}</button>
<!-- Prevent default -->
<a (click)="handleClick($event)">Link</a>
<!-- In component -->
handleClick(event: Event): void {
event.preventDefault();
// Handle click
}
// my-component.component.scss
:host {
display: block; // Set host display
}
.container {
// Scoped to this component only
}
// Use CSS variables for theming
.theme-element {
background-color: var(--nav-bg-color, #2c3e50);
color: var(--text-color, white);
}
// Dark navigation
$nav-bg-primary: #2c3e50;
$nav-bg-hover: #34495e;
// Note highlighting
$root-note-color: #e74c3c; // Red for root
$in-mode-note-color: #3498db; // Blue for scale tones
$out-of-mode-color: #95a5a6; // Gray for chromatic
import * as Tone from 'tone';
export class AudioComponent implements OnDestroy {
private synth: Tone.PolySynth | null = null;
async initAudio(): Promise<void> {
await Tone.start();
this.synth = new Tone.PolySynth(Tone.Synth).toDestination();
}
playNote(note: string): void {
this.synth?.triggerAttackRelease(note, '8n');
}
ngOnDestroy(): void {
// CRITICAL: Dispose audio resources
this.synth?.dispose();
this.synth = null;
}
}
export class DataComponent implements OnInit {
isLoading = true;
data: any[] = [];
ngOnInit(): void {
this.loadData();
}
private async loadData(): Promise<void> {
this.isLoading = true;
try {
this.data = await this.service.getData();
} finally {
this.isLoading = false;
}
}
}
import { FormsModule } from '@angular/forms';
@Component({
// ...
imports: [CommonModule, FormsModule]
})
export class FormComponent {
selectedValue = '';
onSelectionChange(value: string): void {
this.selectedValue = value;
this.musicTheoryService.setSelectedKey(value);
}
}
<select [(ngModel)]="selectedValue" (ngModelChange)="onSelectionChange($event)">
<option *ngFor="let option of options" [value]="option.id">
{{ option.name }}
</option>
</select>
When integrating external libraries that emit events outside Angular's zone (like alphaTab, IndexedDB callbacks), use ChangeDetectorRef to manually trigger change detection:
import { ChangeDetectorRef } from '@angular/core';
export class MyComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
constructor(
private externalService: ExternalService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
// Subscribe to service state
this.externalService.getState()
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.state = state;
// Force change detection for events from outside Angular zone
this.cdr.detectChanges();
});
}
}
When to use ChangeDetectorRef:
| Anti-Pattern | Problem | Correct Approach |
|---|---|---|
| NgModule components | Inconsistent with codebase | Use standalone: true |
| Direct state mutation | Bypasses change detection | Use service methods |
| Missing takeUntil | Memory leaks | Always use destroy$ pattern |
| Computations in template | Performance issues | Use methods or pipes |
| Missing trackBy | Poor list performance | Always provide trackBy |
| Audio without disposal | Memory/resource leak | Dispose in ngOnDestroy |
| Missing ChangeDetectorRef | UI not updating from external events | Use cdr.detectChanges() |
When creating/modifying a component:
standalone: trueOnDestroy implementationtakeUntil(this.destroy$) for subscriptionsFor larger components (like GP viewer), use lazy loading to reduce initial bundle size:
// app-routing.module.ts
const routes: Routes = [
{ path: 'fretboard', component: FretboardComponent }, // Eager load core feature
{
path: 'gp-viewer',
loadComponent: () => import('./components/gp-viewer/gp-viewer.component')
.then(m => m.GpViewerComponent) // Lazy load
},
{
path: 'gp-library',
loadComponent: () => import('./components/gp-library/gp-library.component')
.then(m => m.GpLibraryComponent)
}
];
For deep linking (e.g., opening a file from library):
import { ActivatedRoute } from '@angular/router';
export class MyComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
// Subscribe to query params
this.route.queryParams
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
if (params['id']) {
this.loadItem(params['id']);
}
});
}
}
Navigate with query params:
this.router.navigate(['/gp-viewer'], {
queryParams: { id: entry.id }
});
For client-side storage (like GP Library):
@Injectable({ providedIn: 'root' })
export class MyStorageService {
private db: IDBDatabase | null = null;
private entriesSubject = new BehaviorSubject<MyEntry[]>([]);
constructor(private ngZone: NgZone) {
this.initDatabase();
}
getEntries(): Observable<MyEntry[]> {
return this.entriesSubject.asObservable();
}
private initDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('my-db', 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('items')) {
const store = db.createObjectStore('items', { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
}
};
request.onsuccess = () => {
this.db = request.result;
this.loadAllEntries();
resolve();
};
});
}
// IMPORTANT: Wrap callbacks with NgZone.run() for change detection
private loadAllEntries(): void {
const request = this.db!.transaction('items', 'readonly')
.objectStore('items').getAll();
request.onsuccess = () => {
this.ngZone.run(() => {
this.entriesSubject.next(request.result || []);
});
};
}
}
Use Angular CLI:
# Generate standalone component
ng generate component components/my-component --standalone
# This creates:
# - my-component.component.ts
# - my-component.component.html
# - my-component.component.scss
# - my-component.component.spec.ts