Angular Design Patterns
Introduction
Design patterns in Angular are established solutions to common architectural challenges that developers face when building applications. These patterns provide structured approaches to organizing your code, improving maintainability, and enabling your applications to scale effectively.
As a beginner working with Angular, understanding these design patterns will help you create more robust applications and collaborate better with other developers who use these common approaches.
In this guide, we'll explore essential Angular design patterns, provide practical examples, and show you how to implement them in your own projects.
Component Design Patterns
Container and Presentational Components
One of the most useful patterns in Angular is separating your components into two categories:
Container Components
- Manage state and data
- Fetch data from services
- Handle business logic
- Pass data to presentational components
Presentational Components
- Receive data through inputs
- Emit events through outputs
- Focus on UI rendering
- Contain minimal logic
Let's see a practical example:
// container.component.ts
@Component({
selector: 'app-user-dashboard',
template: `
<div class="dashboard">
<h1>User Dashboard</h1>
<app-user-list
[users]="users"
(userSelected)="onUserSelected($event)">
</app-user-list>
</div>
`
})
export class UserDashboardComponent implements OnInit {
users: User[] = [];
constructor(private userService: UserService) {}
ngOnInit() {
this.userService.getUsers().subscribe(data => {
this.users = data;
});
}
onUserSelected(user: User) {
// Handle user selection logic
console.log('Selected user:', user);
}
}
// presentational.component.ts
@Component({
selector: 'app-user-list',
template: `
<div class="user-list">
<div *ngFor="let user of users"
class="user-card"
(click)="selectUser(user)">
<img [src]="user.avatar" alt="User avatar">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
<div *ngIf="users.length === 0">No users found</div>
</div>
`,
styleUrls: ['./user-list.component.css']
})
export class UserListComponent {
@Input() users: User[] = [];
@Output() userSelected = new EventEmitter<User>();
selectUser(user: User) {
this.userSelected.emit(user);
}
}
Benefits:
- Clear separation of concerns
- Enhanced reusability of presentational components
- Easier testing
- Simplified component logic
Smart and Dumb Components
This pattern is very similar to Container/Presentational but uses different terminology:
- Smart Components: Handle data fetching and state management
- Dumb Components: Focus purely on presentation with inputs and outputs
Service Design Patterns
Singleton Services
In Angular, services are naturally singletons when provided at the root level. This pattern is useful for sharing data and functionality across components.
// shared-data.service.ts
@Injectable({
providedIn: 'root' // This makes it a singleton
})
export class SharedDataService {
private data: any[] = [];
getData(): any[] {
return this.data;
}
addData(item: any): void {
this.data.push(item);
}
}
Usage in components:
// component.ts
@Component({
selector: 'app-data-display',
template: `<div *ngFor="let item of dataItems">{{ item.name }}</div>`
})
export class DataDisplayComponent implements OnInit {
dataItems: any[] = [];
constructor(private sharedData: SharedDataService) {}
ngOnInit() {
this.dataItems = this.sharedData.getData();
}
}
Data Service Pattern
This pattern separates data retrieval from components:
// product.service.ts
@Injectable({
providedIn: 'root'
})
export class ProductService {
constructor(private http: HttpClient) {}
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products');
}
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(`/api/products/${id}`);
}
createProduct(product: Product): Observable<Product> {
return this.http.post<Product>('/api/products', product);
}
}
State Management Patterns
Observable Data Services
This pattern uses RxJS to manage state in services that components can subscribe to.
// user-store.service.ts
@Injectable({
providedIn: 'root'
})
export class UserStoreService {
// Private subject to track the current state
private usersSubject = new BehaviorSubject<User[]>([]);
// Public observable that components can subscribe to
users$ = this.usersSubject.asObservable();
constructor(private http: HttpClient) {
// Initial data load
this.loadUsers();
}
loadUsers() {
this.http.get<User[]>('/api/users')
.pipe(
catchError(error => {
console.error('Failed to load users', error);
return of([]);
})
)
.subscribe(users => {
this.usersSubject.next(users);
});
}
addUser(user: User) {
this.http.post<User>('/api/users', user)
.pipe(
catchError(error => {
console.error('Failed to add user', error);
return of(null);
})
)
.subscribe(newUser => {
if (newUser) {
const currentUsers = this.usersSubject.getValue();
this.usersSubject.next([...currentUsers, newUser]);
}
});
}
}
In a component:
@Component({
selector: 'app-user-list',
template: `
<div *ngFor="let user of users$ | async">
{{ user.name }}
</div>
<button (click)="addNewUser()">Add User</button>
`
})
export class UserListComponent {
users$ = this.userStore.users$;
constructor(private userStore: UserStoreService) {}
addNewUser() {
const newUser = { name: 'New User', email: '[email protected]' };
this.userStore.addUser(newUser);
}
}
NGRX Store Pattern
For more complex applications, you might want to use NGRX, which implements the Redux pattern:
// user.actions.ts
export const loadUsers = createAction('[User] Load Users');
export const loadUsersSuccess = createAction(
'[User] Load Users Success',
props<{ users: User[] }>()
);
// user.reducer.ts
export const userReducer = createReducer(
initialState,
on(loadUsersSuccess, (state, { users }) => ({
...state,
users
}))
);
// user.effects.ts
@Injectable()
export class UserEffects {
loadUsers$ = createEffect(() => this.actions$.pipe(
ofType(loadUsers),
switchMap(() => this.userService.getUsers().pipe(
map(users => loadUsersSuccess({ users }))
))
));
constructor(
private actions$: Actions,
private userService: UserService
) {}
}
Communication Patterns
Input/Output Pattern
The basic communication pattern using @Input()
and @Output()
decorators:
// parent.component.ts
@Component({
selector: 'app-parent',
template: `
<app-child
[data]="parentData"
(dataChanged)="handleDataChange($event)">
</app-child>
`
})
export class ParentComponent {
parentData = { name: 'Initial Data' };
handleDataChange(newData: any) {
console.log('Data changed:', newData);
this.parentData = newData;
}
}
// child.component.ts
@Component({
selector: 'app-child',
template: `
<div>
<h2>Child Component</h2>
<p>Data: {{ data.name }}</p>
<button (click)="changeData()">Change Data</button>
</div>
`
})
export class ChildComponent {
@Input() data: any;
@Output() dataChanged = new EventEmitter<any>();
changeData() {
const newData = { name: 'Updated Data' };
this.dataChanged.emit(newData);
}
}
Service Mediator Pattern
When components aren't directly related, using a service to communicate:
// notification.service.ts
@Injectable({
providedIn: 'root'
})
export class NotificationService {
private notificationSubject = new Subject<string>();
notifications$ = this.notificationSubject.asObservable();
sendNotification(message: string) {
this.notificationSubject.next(message);
}
}
// sender.component.ts
@Component({
selector: 'app-sender',
template: `<button (click)="notify()">Send Notification</button>`
})
export class SenderComponent {
constructor(private notificationService: NotificationService) {}
notify() {
this.notificationService.sendNotification('Hello from Sender!');
}
}
// receiver.component.ts
@Component({
selector: 'app-receiver',
template: `<div *ngIf="message">New message: {{ message }}</div>`
})
export class ReceiverComponent implements OnInit, OnDestroy {
message: string;
private subscription: Subscription;
constructor(private notificationService: NotificationService) {}
ngOnInit() {
this.subscription = this.notificationService.notifications$
.subscribe(msg => {
this.message = msg;
});
}
ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}
Module Organization Patterns
Feature Modules
Break your application into feature modules to improve maintainability:
// user.module.ts
@NgModule({
declarations: [
UserListComponent,
UserDetailComponent,
UserFormComponent
],
imports: [
CommonModule,
UserRoutingModule,
SharedModule
],
providers: [
UserService
]
})
export class UserModule { }
Shared Modules
Create shared modules for components, pipes, and directives used across features:
// shared.module.ts
@NgModule({
declarations: [
HighlightDirective,
TruncatePipe,
LoadingSpinnerComponent
],
imports: [
CommonModule
],
exports: [
// Export items to make them available to importing modules
HighlightDirective,
TruncatePipe,
LoadingSpinnerComponent
]
})
export class SharedModule { }
Real-World Application: Todo List
Let's see how these patterns work together in a real-world example:
// models/todo.model.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
// services/todo.service.ts
@Injectable({
providedIn: 'root'
})
export class TodoService {
private todosSubject = new BehaviorSubject<Todo[]>([]);
todos$ = this.todosSubject.asObservable();
constructor(private http: HttpClient) {}
loadTodos() {
this.http.get<Todo[]>('https://jsonplaceholder.typicode.com/todos?_limit=10')
.subscribe(todos => {
this.todosSubject.next(todos);
});
}
addTodo(title: string) {
const newTodo: Partial<Todo> = {
title,
completed: false
};
this.http.post<Todo>('https://jsonplaceholder.typicode.com/todos', newTodo)
.subscribe(createdTodo => {
const currentTodos = this.todosSubject.getValue();
this.todosSubject.next([...currentTodos, createdTodo]);
});
}
toggleTodo(id: number) {
const currentTodos = this.todosSubject.getValue();
const todoIndex = currentTodos.findIndex(t => t.id === id);
if (todoIndex > -1) {
const todo = currentTodos[todoIndex];
const updatedTodo = { ...todo, completed: !todo.completed };
this.http.put<Todo>(`https://jsonplaceholder.typicode.com/todos/${id}`, updatedTodo)
.subscribe(() => {
const newTodos = [...currentTodos];
newTodos[todoIndex] = updatedTodo;
this.todosSubject.next(newTodos);
});
}
}
}
// containers/todo-container.component.ts
@Component({
selector: 'app-todo-container',
template: `
<div class="todo-app">
<h1>Todo Application</h1>
<app-todo-form (addTodo)="onAddTodo($event)"></app-todo-form>
<app-todo-list
[todos]="(todos$ | async) || []"
(toggleTodo)="onToggleTodo($event)">
</app-todo-list>
</div>
`
})
export class TodoContainerComponent implements OnInit {
todos$ = this.todoService.todos$;
constructor(private todoService: TodoService) {}
ngOnInit() {
this.todoService.loadTodos();
}
onAddTodo(title: string) {
this.todoService.addTodo(title);
}
onToggleTodo(id: number) {
this.todoService.toggleTodo(id);
}
}
// components/todo-form.component.ts
@Component({
selector: 'app-todo-form',
template: `
<form (ngSubmit)="submitTodo()">
<input
type="text"
placeholder="Add new todo..."
[(ngModel)]="todoTitle"
name="todoTitle"
required>
<button type="submit" [disabled]="!todoTitle">Add</button>
</form>
`,
styles: [`
form {
display: flex;
margin-bottom: 20px;
}
input {
flex: 1;
padding: 8px;
font-size: 16px;
}
button {
padding: 8px 16px;
background: #4285f4;
color: white;
border: none;
cursor: pointer;
}
button:disabled {
background: #cccccc;
}
`]
})
export class TodoFormComponent {
todoTitle = '';
@Output() addTodo = new EventEmitter<string>();
submitTodo() {
if (this.todoTitle.trim()) {
this.addTodo.emit(this.todoTitle.trim());
this.todoTitle = '';
}
}
}
// components/todo-list.component.ts
@Component({
selector: 'app-todo-list',
template: `
<div class="todo-list">
<app-todo-item
*ngFor="let todo of todos"
[todo]="todo"
(toggle)="onToggle(todo.id)">
</app-todo-item>
<div *ngIf="todos.length === 0" class="empty-state">
No todos yet. Add one above!
</div>
</div>
`,
styles: [`
.todo-list {
border: 1px solid #ddd;
border-radius: 4px;
}
.empty-state {
padding: 16px;
text-align: center;
color: #777;
}
`]
})
export class TodoListComponent {
@Input() todos: Todo[] = [];
@Output() toggleTodo = new EventEmitter<number>();
onToggle(id: number) {
this.toggleTodo.emit(id);
}
}
// components/todo-item.component.ts
@Component({
selector: 'app-todo-item',
template: `
<div class="todo-item" [class.completed]="todo.completed">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggle.emit()">
<span class="title">{{ todo.title }}</span>
</div>
`,
styles: [`
.todo-item {
padding: 12px 16px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item.completed .title {
text-decoration: line-through;
color: #888;
}
.title {
margin-left: 8px;
}
`]
})
export class TodoItemComponent {
@Input() todo!: Todo;
@Output() toggle = new EventEmitter<void>();
}
In this Todo List application, we've implemented several design patterns:
- Container/Presentational pattern:
TodoContainerComponent
manages state, whileTodoListComponent
andTodoItemComponent
are presentational - Service pattern:
TodoService
handles data operations - Observable Data Service: State is managed through observables in the service
- Input/Output pattern: Child components communicate with parents via inputs and outputs
Summary
Angular design patterns provide structured approaches to common architecture challenges. In this guide, we explored several key patterns:
-
Component Design Patterns:
- Container/Presentational (Smart/Dumb) components
-
Service Design Patterns:
- Singleton services
- Data service pattern
-
State Management Patterns:
- Observable data services
- NGRX/Redux pattern
-
Communication Patterns:
- Input/Output pattern
- Service mediator pattern
-
Module Organization Patterns:
- Feature modules
- Shared modules
By applying these patterns in your Angular applications, you can improve code organization, maintainability, and scalability while following established best practices.
Additional Resources
- Angular Official Documentation
- NGRX Store Documentation
- RxJS Documentation
- Book: "Angular Design Patterns" by Mathieu Nayrolles
- Book: "Learning Angular" by Aristeidis Bampakos and Pablo Deeleman
Exercises
- Container/Presentational Practice: Convert an existing component in your application into container and presentational components.
- Service Communication: Create a notification system using the service mediator pattern.
- State Management: Implement an observable data service for a shopping cart feature.
- Todo App Extension: Extend the todo application with features like categories, due dates, and filters using the patterns learned.
- Module Organization: Organize a medium-sized application into feature modules and shared modules.
Remember that these patterns are guidelines, not strict rules. Adapt them to fit your application's specific requirements and complexity level.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)