Kuasai Angular dari dasar. Pelajari mengapa Angular diciptakan, pahami core concepts seperti modules, components, services, dependency injection, directives, pipes, forms, HTTP, routing, RxJS, dan change detection. Bangun complete production-ready todo application yang cover semua Angular fundamentals dengan best practices.

Angular telah menjadi salah satu framework frontend paling comprehensive dan opinionated untuk building large-scale, enterprise-grade web applications. Tapi mengapa Angular ada, dan apa yang membuatnya berbeda dari framework lain? Understanding Angular's philosophy dan core concepts adalah essential untuk building scalable, maintainable, dan production-ready applications.
Dalam artikel ini, kita akan explore Angular's history, understand mengapa Angular diciptakan, dive deep ke core concepts, dan build complete production-ready todo application yang demonstrate semua fundamental Angular patterns.
Sebelum Angular, building large-scale web applications sangat challenging:
Google created Angular (originally AngularJS di 2010, kemudian Angular 2+ di 2016) untuk solve problems ini:
Modules adalah containers untuk cohesive block dari code yang dedicated ke application domain, workflow, atau closely related set dari capabilities.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { TodoListComponent } from './components/todo-list/todo-list.component';
@NgModule({
declarations: [
AppComponent,
TodoListComponent,
],
imports: [
BrowserModule,
HttpClientModule,
ReactiveFormsModule,
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule { }Components adalah basic building blocks dari Angular applications. Component controls patch dari screen yang disebut view.
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-todo-item',
template: `
<div class="todo-item">
<input
type="checkbox"
[checked]="todo.completed"
(change)="onToggle()"
/>
<span [class.completed]="todo.completed">{{ todo.title }}</span>
<button (click)="onDelete()">Delete</button>
</div>
`,
styles: [`
.todo-item {
display: flex;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #eee;
}
.completed {
text-decoration: line-through;
color: #999;
}
`],
})
export class TodoItemComponent implements OnInit {
@Input() todo: any;
@Output() toggle = new EventEmitter<void>();
@Output() delete = new EventEmitter<void>();
ngOnInit(): void {
console.log('TodoItemComponent initialized');
}
onToggle(): void {
this.toggle.emit();
}
onDelete(): void {
this.delete.emit();
}
}Services adalah classes dengan focused purpose. Mereka digunakan untuk share data dan logic across components.
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { map, tap } from 'rxjs/operators';
export interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
dueDate: string;
createdAt: Date;
}
@Injectable({
providedIn: 'root',
})
export class TodoService {
private apiUrl = '/api/todos';
private todosSubject = new BehaviorSubject<Todo[]>([]);
public todos$ = this.todosSubject.asObservable();
constructor(private http: HttpClient) {
this.loadTodos();
}
loadTodos(): void {
this.http.get<Todo[]>(this.apiUrl)
.pipe(
tap(todos => this.todosSubject.next(todos))
)
.subscribe();
}
getTodos(): Observable<Todo[]> {
return this.todos$;
}
addTodo(todo: Omit<Todo, 'id' | 'createdAt'>): Observable<Todo> {
return this.http.post<Todo>(this.apiUrl, todo)
.pipe(
tap(newTodo => {
const current = this.todosSubject.value;
this.todosSubject.next([newTodo, ...current]);
})
);
}
updateTodo(id: string, updates: Partial<Todo>): Observable<Todo> {
return this.http.patch<Todo>(`${this.apiUrl}/${id}`, updates)
.pipe(
tap(updated => {
const current = this.todosSubject.value;
const index = current.findIndex(t => t.id === id);
if (index !== -1) {
current[index] = updated;
this.todosSubject.next([...current]);
}
})
);
}
deleteTodo(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`)
.pipe(
tap(() => {
const current = this.todosSubject.value;
this.todosSubject.next(current.filter(t => t.id !== id));
})
);
}
}Directives adalah classes yang add new behavior ke DOM atau modify existing behavior.
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
// Structural Directive - modifies DOM structure
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {
@Input() appHighlight = 'yellow';
constructor(private el: ElementRef) {}
@HostListener('mouseenter') onMouseEnter(): void {
this.highlight(this.appHighlight);
}
@HostListener('mouseleave') onMouseLeave(): void {
this.highlight('transparent');
}
private highlight(color: string): void {
this.el.nativeElement.style.backgroundColor = color;
}
}
// Usage dalam template
// <div appHighlight="lightblue">Hover me</div>
// Built-in Structural Directives
// *ngIf - conditionally render
// *ngFor - loop through arrays
// *ngSwitch - switch between templates
// Template example
/*
<div *ngIf="isVisible">Visible</div>
<div *ngFor="let item of items; let i = index">{{ i }}: {{ item }}</div>
<div [ngSwitch]="status">
<div *ngSwitchCase="'active'">Active</div>
<div *ngSwitchCase="'inactive'">Inactive</div>
<div *ngSwitchDefault>Unknown</div>
</div>
*/Pipes transform data untuk display dalam templates.
import { Pipe, PipeTransform } from '@angular/core';
// Custom Pipe
@Pipe({
name: 'truncate',
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit: number = 50): string {
if (!value) return '';
return value.length > limit ? value.substring(0, limit) + '...' : value;
}
}
// Built-in Pipes
/*
{{ date | date: 'short' }}
{{ price | currency: 'USD' }}
{{ text | uppercase }}
{{ text | lowercase }}
{{ items | slice: 0:5 }}
{{ value | number: '1.2-2' }}
{{ obj | json }}
*/
// Template example
/*
<p>{{ todo.title | truncate: 30 }}</p>
<p>{{ todo.dueDate | date: 'MMM d, y' }}</p>
<p>{{ todo.priority | uppercase }}</p>
*/Angular provides dua approaches untuk handling forms: Reactive Forms dan Template-driven Forms.
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-todo-form',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label>Title</label>
<input
type="text"
formControlName="title"
placeholder="Enter task title"
/>
<div *ngIf="form.get('title')?.invalid && form.get('title')?.touched">
Title is required
</div>
</div>
<div>
<label>Description</label>
<textarea
formControlName="description"
placeholder="Enter task description"
></textarea>
</div>
<div>
<label>Priority</label>
<select formControlName="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div>
<label>Due Date</label>
<input
type="date"
formControlName="dueDate"
/>
</div>
<button type="submit" [disabled]="form.invalid">Add Task</button>
</form>
`,
})
export class TodoFormComponent implements OnInit {
form!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
this.form = this.fb.group({
title: ['', [Validators.required, Validators.minLength(3)]],
description: [''],
priority: ['medium'],
dueDate: [''],
});
}
onSubmit(): void {
if (this.form.valid) {
console.log(this.form.value);
this.form.reset();
}
}
}HttpClient service digunakan untuk communicate dengan backend servers over HTTP.
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, retry, timeout } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class ApiService {
private apiUrl = 'https://api.example.com';
constructor(private http: HttpClient) {}
// GET request
getTodos(): Observable<any[]> {
return this.http.get<any[]>(`${this.apiUrl}/todos`)
.pipe(
timeout(5000),
retry(1),
catchError(this.handleError)
);
}
// POST request
createTodo(todo: any): Observable<any> {
const headers = new HttpHeaders({
'Content-Type': 'application/json',
});
return this.http.post<any>(
`${this.apiUrl}/todos`,
todo,
{ headers }
).pipe(
catchError(this.handleError)
);
}
// PUT request
updateTodo(id: string, todo: any): Observable<any> {
return this.http.put<any>(
`${this.apiUrl}/todos/${id}`,
todo
).pipe(
catchError(this.handleError)
);
}
// DELETE request
deleteTodo(id: string): Observable<void> {
return this.http.delete<void>(
`${this.apiUrl}/todos/${id}`
).pipe(
catchError(this.handleError)
);
}
// Query parameters
searchTodos(query: string): Observable<any[]> {
const params = new HttpParams()
.set('q', query)
.set('limit', '10');
return this.http.get<any[]>(
`${this.apiUrl}/todos/search`,
{ params }
).pipe(
catchError(this.handleError)
);
}
private handleError(error: any): Observable<never> {
console.error('API Error:', error);
return throwError(() => new Error('API request failed'));
}
}Angular Router enables navigation antara components berdasarkan URL.
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TodoListComponent } from './components/todo-list/todo-list.component';
import { TodoDetailComponent } from './components/todo-detail/todo-detail.component';
import { AuthGuard } from './guards/auth.guard';
const routes: Routes = [
{
path: '',
redirectTo: '/todos',
pathMatch: 'full',
},
{
path: 'todos',
component: TodoListComponent,
canActivate: [AuthGuard],
},
{
path: 'todos/:id',
component: TodoDetailComponent,
canActivate: [AuthGuard],
},
{
path: '**',
redirectTo: '/todos',
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule { }
// Route Guard
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private router: Router) {}
canActivate(): boolean {
const isAuthenticated = !!localStorage.getItem('token');
if (!isAuthenticated) {
this.router.navigate(['/login']);
}
return isAuthenticated;
}
}Observables adalah key part dari Angular untuk handling asynchronous operations.
import { Observable, Subject, BehaviorSubject, interval } from 'rxjs';
import { map, filter, switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators';
// Creating Observables
const observable$ = new Observable(subscriber => {
subscriber.next(1);
subscriber.next(2);
subscriber.next(3);
subscriber.complete();
});
// Subject - multicast observable
const subject$ = new Subject<number>();
subject$.subscribe(value => console.log('Observer 1:', value));
subject$.subscribe(value => console.log('Observer 2:', value));
subject$.next(42);
// BehaviorSubject - always has current value
const behaviorSubject$ = new BehaviorSubject<string>('initial');
behaviorSubject$.subscribe(value => console.log(value));
behaviorSubject$.next('updated');
// Common Operators
const numbers$ = interval(1000).pipe(
// Transform values
map(x => x * 2),
// Filter values
filter(x => x > 5),
// Flatten nested observables
switchMap(x => new Observable(sub => sub.next(x * 10))),
// Debounce rapid emissions
debounceTime(300),
// Only emit if value changed
distinctUntilChanged()
);
// Combining Observables
import { combineLatest, merge, forkJoin } from 'rxjs';
const combined$ = combineLatest([observable1$, observable2$]);
const merged$ = merge(observable1$, observable2$);
const forked$ = forkJoin([observable1$, observable2$]);Angular's change detection mechanism determines kapan untuk update view.
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
// Default Strategy - checks entire component tree
@Component({
selector: 'app-default',
template: `<p>{{ data }}</p>`,
})
export class DefaultComponent {
data = 'Initial';
}
// OnPush Strategy - only checks ketika inputs change atau events occur
@Component({
selector: 'app-on-push',
template: `<p>{{ data }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OnPushComponent {
@Input() data: string = '';
}
// Manual Change Detection
@Component({
selector: 'app-manual',
template: `<p>{{ data }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ManualComponent {
data = 'Initial';
constructor(private cdr: ChangeDetectorRef) {}
updateData(): void {
this.data = 'Updated';
// Manually trigger change detection
this.cdr.markForCheck();
}
}Mari kita build complete todo management application yang demonstrate semua Angular fundamentals.
todo-app/
├── src/
│ ├── app/
│ │ ├── models/
│ │ │ └── todo.model.ts
│ │ ├── services/
│ │ │ └── todo.service.ts
│ │ ├── components/
│ │ │ ├── todo-list/
│ │ │ ├── todo-item/
│ │ │ ├── todo-form/
│ │ │ └── todo-filter/
│ │ ├── app.component.ts
│ │ ├── app.module.ts
│ │ └── app-routing.module.ts
│ ├── styles.css
│ └── main.ts
├── angular.json
└── package.jsonexport interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
dueDate: string;
createdAt: Date;
}
export type FilterType = 'all' | 'active' | 'completed';
export interface TodoStats {
total: number;
active: number;
completed: number;
}import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { Todo } from '../models/todo.model';
@Injectable({
providedIn: 'root',
})
export class TodoService {
private apiUrl = '/api/todos';
private todosSubject = new BehaviorSubject<Todo[]>([]);
public todos$ = this.todosSubject.asObservable();
constructor(private http: HttpClient) {
this.loadTodos();
}
loadTodos(): void {
this.http.get<Todo[]>(this.apiUrl)
.pipe(
tap(todos => this.todosSubject.next(todos)),
catchError(error => {
console.error('Error loading todos:', error);
return throwError(() => error);
})
)
.subscribe();
}
getTodos(): Observable<Todo[]> {
return this.todos$;
}
addTodo(todo: Omit<Todo, 'id' | 'createdAt'>): Observable<Todo> {
return this.http.post<Todo>(this.apiUrl, todo)
.pipe(
tap(newTodo => {
const current = this.todosSubject.value;
this.todosSubject.next([newTodo, ...current]);
}),
catchError(error => {
console.error('Error adding todo:', error);
return throwError(() => error);
})
);
}
updateTodo(id: string, updates: Partial<Todo>): Observable<Todo> {
return this.http.patch<Todo>(`${this.apiUrl}/${id}`, updates)
.pipe(
tap(updated => {
const current = this.todosSubject.value;
const index = current.findIndex(t => t.id === id);
if (index !== -1) {
current[index] = updated;
this.todosSubject.next([...current]);
}
}),
catchError(error => {
console.error('Error updating todo:', error);
return throwError(() => error);
})
);
}
deleteTodo(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`)
.pipe(
tap(() => {
const current = this.todosSubject.value;
this.todosSubject.next(current.filter(t => t.id !== id));
}),
catchError(error => {
console.error('Error deleting todo:', error);
return throwError(() => error);
})
);
}
toggleTodo(id: string): Observable<Todo> {
const todo = this.todosSubject.value.find(t => t.id === id);
if (!todo) {
return throwError(() => new Error('Todo not found'));
}
return this.updateTodo(id, { completed: !todo.completed });
}
}import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TodoService } from '../../services/todo.service';
import { Todo, FilterType, TodoStats } from '../../models/todo.model';
import { TodoItemComponent } from '../todo-item/todo-item.component';
import { TodoFormComponent } from '../todo-form/todo-form.component';
import { TodoFilterComponent } from '../todo-filter/todo-filter.component';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [
CommonModule,
TodoItemComponent,
TodoFormComponent,
TodoFilterComponent,
],
template: `
<div class="todo-container">
<header class="app-header">
<h1>Task Manager</h1>
<p>Organize your tasks efficiently</p>
</header>
<main class="app-main">
<app-todo-form (taskAdded)="onTaskAdded()"></app-todo-form>
<app-todo-filter
[activeFilter]="filter"
[stats]="stats"
(filterChanged)="onFilterChange($event)"
></app-todo-filter>
<div class="task-list" *ngIf="filteredTodos.length > 0">
<app-todo-item
*ngFor="let todo of filteredTodos; trackBy: trackByTodoId"
[todo]="todo"
(toggle)="onToggleTodo(todo.id)"
(delete)="onDeleteTodo(todo.id)"
></app-todo-item>
</div>
<div class="empty-state" *ngIf="filteredTodos.length === 0">
<p>No tasks found. {{ filter !== 'all' ? 'Try changing the filter.' : '' }}</p>
</div>
</main>
</div>
`,
styles: [`
.todo-container {
max-width: 800px;
margin: 0 auto;
}
.app-header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.app-header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.app-header p {
font-size: 1.1rem;
opacity: 0.9;
}
.app-main {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.task-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #9ca3af;
}
`],
})
export class TodoListComponent implements OnInit {
todos: Todo[] = [];
filter: FilterType = 'all';
stats: TodoStats = { total: 0, active: 0, completed: 0 };
get filteredTodos(): Todo[] {
return this.todos.filter(todo => {
if (this.filter === 'active') return !todo.completed;
if (this.filter === 'completed') return todo.completed;
return true;
});
}
constructor(private todoService: TodoService) {}
ngOnInit(): void {
this.todoService.getTodos().subscribe(todos => {
this.todos = todos;
this.updateStats();
});
}
onTaskAdded(): void {
this.todoService.loadTodos();
}
onToggleTodo(id: string): void {
this.todoService.toggleTodo(id).subscribe();
}
onDeleteTodo(id: string): void {
this.todoService.deleteTodo(id).subscribe();
}
onFilterChange(filter: FilterType): void {
this.filter = filter;
}
private updateStats(): void {
this.stats = {
total: this.todos.length,
active: this.todos.filter(t => !t.completed).length,
completed: this.todos.filter(t => t.completed).length,
};
}
trackByTodoId(index: number, todo: Todo): string {
return todo.id;
}
}import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Todo } from '../../models/todo.model';
@Component({
selector: 'app-todo-item',
standalone: true,
imports: [CommonModule],
template: `
<div class="task-item" [class.completed]="todo.completed">
<div class="task-content">
<input
type="checkbox"
[checked]="todo.completed"
(change)="onToggle()"
class="task-checkbox"
/>
<div class="task-details">
<h3 class="task-title">{{ todo.title }}</h3>
<p class="task-description" *ngIf="todo.description">
{{ todo.description }}
</p>
<div class="task-meta">
<span
class="priority-badge"
[style.backgroundColor]="getPriorityColor(todo.priority)"
>
{{ todo.priority }}
</span>
<span class="due-date" *ngIf="todo.dueDate">
Due: {{ todo.dueDate | date: 'MMM d, y' }}
</span>
</div>
</div>
</div>
<button (click)="onDelete()" class="btn btn-danger btn-small">
Delete
</button>
</div>
`,
styles: [`
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid #667eea;
transition: all 0.2s;
}
.task-item:hover {
background: #f3f4f6;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.task-item.completed {
opacity: 0.6;
border-left-color: #10b981;
}
.task-item.completed .task-title {
text-decoration: line-through;
color: #9ca3af;
}
.task-content {
display: flex;
gap: 12px;
flex: 1;
}
.task-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
margin-top: 2px;
}
.task-details {
flex: 1;
}
.task-title {
font-size: 1rem;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.task-description {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 8px;
}
.task-meta {
display: flex;
gap: 10px;
align-items: center;
}
.priority-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.due-date {
font-size: 0.875rem;
color: #6b7280;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-small {
padding: 6px 12px;
font-size: 0.875rem;
}
`],
})
export class TodoItemComponent {
@Input() todo!: Todo;
@Output() toggle = new EventEmitter<void>();
@Output() delete = new EventEmitter<void>();
onToggle(): void {
this.toggle.emit();
}
onDelete(): void {
this.delete.emit();
}
getPriorityColor(priority: string): string {
const colors: Record<string, string> = {
low: '#3b82f6',
medium: '#f59e0b',
high: '#ef4444',
};
return colors[priority] || '#3b82f6';
}
}import { Component, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TodoService } from '../../services/todo.service';
@Component({
selector: 'app-todo-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="task-form">
<div class="form-group">
<label for="title">Task Title *</label>
<input
id="title"
type="text"
formControlName="title"
placeholder="Enter task title"
class="form-input"
/>
<div class="error" *ngIf="form.get('title')?.invalid && form.get('title')?.touched">
Title is required and must be at least 3 characters
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
formControlName="description"
placeholder="Enter task description"
class="form-input"
rows="3"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" formControlName="priority" class="form-input">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="form-group">
<label for="dueDate">Due Date</label>
<input
id="dueDate"
type="date"
formControlName="dueDate"
class="form-input"
/>
</div>
</div>
<button type="submit" [disabled]="form.invalid" class="btn btn-primary">
Add Task
</button>
</form>
`,
styles: [`
.task-form {
margin-bottom: 30px;
padding-bottom: 30px;
border-bottom: 2px solid #e5e7eb;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #374151;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #ef4444;
font-size: 0.875rem;
margin-top: 4px;
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
`],
})
export class TodoFormComponent {
@Output() taskAdded = new EventEmitter<void>();
form: FormGroup;
constructor(
private fb: FormBuilder,
private todoService: TodoService
) {
this.form = this.fb.group({
title: ['', [Validators.required, Validators.minLength(3)]],
description: [''],
priority: ['medium'],
dueDate: [''],
});
}
onSubmit(): void {
if (this.form.valid) {
this.todoService.addTodo(this.form.value).subscribe({
next: () => {
this.form.reset({ priority: 'medium' });
this.taskAdded.emit();
},
error: (error) => {
console.error('Error adding task:', error);
},
});
}
}
}import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterType, TodoStats } from '../../models/todo.model';
@Component({
selector: 'app-todo-filter',
standalone: true,
imports: [CommonModule],
template: `
<div class="task-filter">
<div class="filter-buttons">
<button
*ngFor="let filterOption of filters"
(click)="onFilterChange(filterOption)"
[class.active]="activeFilter === filterOption"
class="filter-btn"
>
{{ filterOption | titlecase }}
</button>
</div>
<div class="filter-stats">
<span>Total: {{ stats.total }}</span>
<span>Active: {{ stats.active }}</span>
<span>Completed: {{ stats.completed }}</span>
</div>
</div>
`,
styles: [`
.task-filter {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding: 15px;
background: #f9fafb;
border-radius: 8px;
}
.filter-buttons {
display: flex;
gap: 10px;
}
.filter-btn {
padding: 8px 16px;
border: 2px solid #e5e7eb;
background: white;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.filter-btn.active {
border-color: #667eea;
background: #667eea;
color: white;
}
.filter-stats {
display: flex;
gap: 20px;
font-size: 0.875rem;
color: #6b7280;
}
@media (max-width: 640px) {
.task-filter {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.filter-stats {
width: 100%;
justify-content: space-around;
}
}
`],
})
export class TodoFilterComponent {
@Input() activeFilter: FilterType = 'all';
@Input() stats: TodoStats = { total: 0, active: 0, completed: 0 };
@Output() filterChanged = new EventEmitter<FilterType>();
filters: FilterType[] = ['all', 'active', 'completed'];
onFilterChange(filter: FilterType): void {
this.filterChanged.emit(filter);
}
}import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { TodoListComponent } from './components/todo-list/todo-list.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, TodoListComponent],
template: `
<div class="app-wrapper">
<app-todo-list></app-todo-list>
</div>
`,
styles: [`
.app-wrapper {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
`],
})
export class AppComponent {
title = 'todo-app';
}* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
app-root {
display: block;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Focus Styles */
:focus-visible {
outline: 2px solid #667eea;
outline-offset: 2px;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
/* Utility Classes */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.text-center {
text-align: center;
}
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mb-4 { margin-bottom: 2rem; }
.p-1 { padding: 0.5rem; }
.p-2 { padding: 1rem; }
.p-3 { padding: 1.5rem; }
.p-4 { padding: 2rem; }// ✅ Keep components small dan focused
// ✅ Use OnPush change detection untuk performance
// ✅ Implement OnDestroy untuk clean up subscriptions
// ✅ Use trackBy dalam *ngFor untuk performance
// ✅ Prefer composition over inheritance
// ✅ Use standalone components ketika possible
// ✅ Separate concerns: template, logic, styles
@Component({
selector: 'app-user-card',
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent implements OnInit, OnDestroy {
@Input() user!: User;
private destroy$ = new Subject<void>();
ngOnInit(): void {
// Subscribe dengan takeUntil
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}// ✅ Use providedIn: 'root' untuk singleton services
// ✅ Separate API calls dari business logic
// ✅ Use BehaviorSubject untuk state management
// ✅ Handle errors consistently
// ✅ Use typed responses
// ✅ Implement proper error handling
@Injectable({
providedIn: 'root',
})
export class DataService {
private dataSubject = new BehaviorSubject<Data[]>([]);
public data$ = this.dataSubject.asObservable();
constructor(private http: HttpClient) {}
loadData(): void {
this.http.get<Data[]>('/api/data')
.pipe(
catchError(error => {
console.error('Error loading data:', error);
return of([]);
})
)
.subscribe(data => this.dataSubject.next(data));
}
}// ✅ Use async pipe dalam templates
// ✅ Unsubscribe properly dengan takeUntil
// ✅ Use shareReplay untuk expensive operations
// ✅ Combine observables dengan combineLatest
// ✅ Use switchMap untuk dependent requests
export class SearchComponent {
searchTerm$ = new Subject<string>();
results$ = this.searchTerm$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.api.search(term)),
shareReplay(1)
);
constructor(private api: ApiService) {}
}
// Template
// <div *ngFor="let result of results$ | async">{{ result }}</div>// ✅ Use Reactive Forms untuk complex forms
// ✅ Validate on blur, bukan pada setiap keystroke
// ✅ Show validation errors hanya ketika touched
// ✅ Use custom validators untuk business logic
// ✅ Disable submit button saat form invalid
@Component({
selector: 'app-form',
template: `
<form [formGroup]="form">
<input formControlName="email" />
<div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
Invalid email
</div>
<button [disabled]="form.invalid">Submit</button>
</form>
`,
})
export class FormComponent {
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
constructor(private fb: FormBuilder) {}
}// ✅ Test component behavior, bukan implementation
// ✅ Use TestBed untuk component testing
// ✅ Mock services dan HTTP calls
// ✅ Test user interactions
// ✅ Aim untuk high coverage
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TodoComponent } from './todo.component';
describe('TodoComponent', () => {
let component: TodoComponent;
let fixture: ComponentFixture<TodoComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TodoComponent],
}).compileComponents();
fixture = TestBed.createComponent(TodoComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should add todo', () => {
component.addTodo('Test');
expect(component.todos.length).toBe(1);
});
});export class BadComponent implements OnInit {
constructor(private service: DataService) {}
ngOnInit(): void {
// ❌ Never unsubscribed - memory leak!
this.service.getData().subscribe(data => {
console.log(data);
});
}
}
// ✅ Correct - Using takeUntil
export class GoodComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
constructor(private service: DataService) {}
ngOnInit(): void {
this.service.getData()
.pipe(takeUntil(this.destroy$))
.subscribe(data => console.log(data));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}// ❌ Default strategy checks entire tree pada setiap event
@Component({
selector: 'app-item',
template: `<p>{{ item.name }}</p>`,
})
export class ItemComponent {
@Input() item: any;
}
// ✅ Correct - OnPush hanya checks ketika inputs change
@Component({
selector: 'app-item',
template: `<p>{{ item.name }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ItemComponent {
@Input() item: any;
}export class BadComponent {
data: any;
constructor(private service: DataService) {}
ngOnInit(): void {
// ❌ Manual subscription, need to unsubscribe
this.service.getData().subscribe(data => {
this.data = data;
});
}
}
// ✅ Correct - Using async pipe
export class GoodComponent {
data$ = this.service.getData();
constructor(private service: DataService) {}
}
// Template
// <p>{{ data$ | async }}</p>// ❌ No error handling
this.http.get('/api/data').subscribe(data => {
this.data = data;
});
// ✅ Correct - Proper error handling
this.http.get('/api/data').pipe(
catchError(error => {
console.error('Error:', error);
return of([]); // Return default value
})
).subscribe(data => {
this.data = data;
});// ❌ Creates new observable pada setiap change detection
export class BadComponent {
constructor(private service: DataService) {}
getData() {
return this.service.getData(); // Called every render!
}
}
// Template
// <div *ngFor="let item of getData() | async">{{ item }}</div>
// ✅ Correct - Observable created once
export class GoodComponent {
data$ = this.service.getData();
constructor(private service: DataService) {}
}
// Template
// <div *ngFor="let item of data$ | async">{{ item }}</div>Angular adalah powerful, comprehensive framework yang designed untuk building large-scale, enterprise-grade web applications. Understanding core concepts-nya—modules, components, services, dependency injection, directives, pipes, forms, HTTP, routing, RxJS, dan change detection—adalah fundamental untuk menjadi proficient Angular developer.
Todo management application yang kita build demonstrate semua concepts ini dalam action. Dengan mastering fundamentals ini dan following best practices, kamu akan able untuk build scalable, maintainable, dan performant Angular applications.
Key takeaways:
Next steps:
Angular adalah journey, bukan destination. Keep learning, building, dan improving your skills. Framework terus evolve, dan staying updated dengan new features dan best practices akan make you a better developer.