Reuse data or state across interfaces by separating your Angular components based on responsibility, aka the smart/dumb component pattern.
Data is a critical part of any web application, and there are often scenarios where the same data needs to be presented in different UI formats. The smart/dumb component pattern in Angular separates components based on their responsibilities, making it easier to reuse the same data or application state across different UI presentations.
A smart component mainly contains business logic and communication with the outside of the application, such as an API call.
A dumb component is primarily responsible for presenting the UI and rendering the data it receives. It doesn’t handle any business logic or make API calls. It communicates only with its parent smart component, which manages the data and logic.
As you see from the diagram, a smart component is responsible for handling business logic and managing data. It connects to services, APIs or state management; handles user interactions; and passes data to its child components. It typically manages complex state and side effects.
A smart component is also known as a container component.
A dumb component focuses solely on UI and presentation. It receives data via inputs, emits events via models and remains unaware of any services or business logic. It is highly reusable and easy to test, making it ideal for building clean, modular UIs.
A dumb component is also known as a presentational component.
To implement the Smart-Dumb Component design pattern, we’ll work with data fetched from an API endpoint. In this example, the product API returns a list of products, as illustrated in the image below.
We will fetch data from the API inside a service. So, to represent the API response type, let’s define an interface in the Angular application. In the application, add a file product.model.ts:
export interface IProductModel {
id: string;
name: string;
description: string;
price: number;
category: string;
}
After adding the model, add a service to connect with the API. Add a service using the Angular CLI command ng g s product.
In the service, we will use the Angular httpResource API to fetch data from the API.
@Injectable({
providedIn: 'root'
})
export class ProductService {
private apirl = `http://localhost:3000/product`;
private productsResource = httpResource<IProductModel[]>(() => this.apirl );
getProducts(): HttpResourceRef<IProductModel[]|undefined> {
return this.productsResource;
}
}
The httpResource API is a new feature of Angular 20. The httpResource extends the Resource API by using the HttpClient under the hood, providing a seamless way to make HTTP requests while supporting interceptors and existing testing tools.
@angular/common/http.You can read in detail about the httpResource API in my earlier article here: https://www.telerik.com/blogs/getting-started-httpresource-api-angular.
We have written the code to fetch data from the API. The next step is to integrate this code into the smart component.
To create the smart component, run the CLI command ng g c product.
As you might recall from the earlier discussion, a smart component is responsible for handling business logic and managing data. It connects to services, APIs or state management; handles user interactions; and passes data to its child components. We will utilize the Product Service to fetch data within the Product Component, enabling it to function as a smart component.
export class Product {
private productService = inject(ProductService);
products: IProductModel[] = [];
constructor() {
effect(() => {
console.log(this.productService.getProducts().value());
this.products = this.productService.getProducts().value() || [];
console.table(this.products);
})
}
}
To navigate to the product component, add a route in the app.route.ts file as shown below.
export const routes: Routes = [
{
path: 'product',
loadComponent: () => import('./product/product').then(m => m.Product)
},
{
path: '',
redirectTo: '/product',
pathMatch: 'full'
},
];
Now, when you navigate to the /product, you should see products in the smart component.
At this stage, we can create a unit test to verify that the smart component is successfully fetching data from the API.
As discussed earlier, a dumb component focuses solely on UI and presentation. It receives data via inputs, emits events via models and remains unaware of any services or business logic.
Suppose we have a requirement to display products in two formats:
Since we’ve already fetched the data in the smart component, we now need to provide two different UI representations for the same data. For this purpose, we will create two dumb components.
ng g c product-gridng g c product-listAdditionally, we will use Bootstrap to create the UI, so verify that Bootstrap is installed in the project. To do that, run the command below:
npm install bootstrap
And update the angular.json file as below:
"styles": [
"src/styles.scss",
"node_modules/bootstrap/dist/css/bootstrap.min.css"
]
In the dumb component, we will define an input property to receive data from the smart parent component, as shown below.
@Component({
selector: 'app-product-grid',
imports: [],
templateUrl: './product-grid.html',
styleUrl: './product-grid.scss'
})
export class ProductGrid {
products = input<IProductModel[]>();
}
And then products will be displayed in a table as shown below:
<div class="container-fluid mt-3">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Product Name</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody>
@for(product of products(); track product.id) {
<tr>
<th scope="row">{{ product.id }}</th>
<td>{{ product.name }}</td>
<td>${{ product.price.toFixed(2) }}</td>
</tr>
}
@empty {
<tr>
<td colspan="4" class="text-center py-3">No products found</td>
</tr>
}
</tbody>
</table>
</div>
</div>
Similarly, we can create another dumb component called ProductList, as shown below.
@Component({
selector: 'app-product-list',
imports: [],
templateUrl: './product-list.html',
styleUrl: './product-list.scss'
})
export class ProductList {
products = input<IProductModel[]>();
}
And then products will be displayed in a list as shown below:
<div class="container mt-3">
<div class="row">
@for(product of products(); track product.id){
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{{ product.name }}</h5>
<p class="card-text">Price: ${{ product.price }}</p>
</div>
</div>
</div>
}
</div>
</div>
In the smart component, we begin by defining an ng-template, which serves as a placeholder where other dumb components can be dynamically inserted. Next, we add two buttons that give the user control to switch between displaying the ProductGrid and the ProductList components within this placeholder.
<div class="container-fluid p-3">
<div class="d-flex justify-content-start">
<button type="button" class="btn btn-secondary btn-sm me-2" (click)="loadGridView()">
Grid View
</button>
<button type="button" class="btn btn-secondary btn-sm" (click)="loadListView()">
List View
</button>
</div>
</div>
<div>
<ng-template #productemp></ng-template>
</div>
As you see, we create two buttons that load the ProductGrid and ProductList dumb components, and use an ng-template as the placeholder where these components are rendered.
First, let’s read the template reference variable as a ViewChild:
@ViewChild('productemp', { read: ViewContainerRef, static: true })
private productRef?: ViewContainerRef;
Here, the template is read as a ViewContainerRef so that components can be loaded into it dynamically. The static: true option makes this ViewChild available during the ngOnInit lifecycle hook.
Next, inject the service and read the data as a computed signal.
private productService = inject(ProductService);
products = computed(() => this.productService.getProducts().value() || []);
We will load dumb components dynamically, meaning the browser downloads them only when needed. This works like lazy loading for components. To achieve this, we create a function to load the ProductGrid component, as shown below.
async loadGridView() {
if (this.productRef) {
this.productRef.clear();
const { ProductGrid } = await import('../product-grid/product-grid');
const componentRef = this.productRef.createComponent(ProductGrid);
componentRef.setInput('products', this.products());
}
}
To lazy load the dumb component:
ViewContainerRef.import statement to dynamically load the product-grid file.createComponent method on the ViewContainerRef to render the component.One of the most important things, you should notice that we are using setInput() to pass data to the dumb component.
You can read in detail about Lazy Load a Component here: https://www.telerik.com/blogs/how-to-lazy-load-component-angular.
In the same way, we can create a function to load another dumb component ProductList component as shown below:
async loadListView() {
if (this.productRef) {
this.productRef.clear();
const { ProductList } = await import('../product-list/product-list');
const componentRef = this.productRef.createComponent(ProductList);
componentRef.setInput('products', this.products());
}
}
Putting it all together, the complete implementation of the smart component is shown below.
import { Component, computed, effect, inject, signal, ViewChild, ViewContainerRef } from '@angular/core';
import { ProductService } from '../product';
@Component({
selector: 'app-product',
imports: [],
templateUrl: './product.html',
styleUrl: './product.scss'
})
export class Product {
private productService = inject(ProductService);
products = computed(() => this.productService.getProducts().value() || []);
@ViewChild('productemp', { read: ViewContainerRef, static: true })
private productRef?: ViewContainerRef;
async ngOnInit() {
this.loadGridView();
}
async loadGridView() {
if (this.productRef) {
this.productRef.clear();
const { ProductGrid } = await import('../product-grid/product-grid');
const componentRef = this.productRef.createComponent(ProductGrid);
componentRef.setInput('products', this.products());
}
}
async loadListView() {
if (this.productRef) {
this.productRef.clear();
const { ProductList } = await import('../product-list/product-list');
const componentRef = this.productRef.createComponent(ProductList);
componentRef.setInput('products', this.products());
}
}
}
With the smart-dumb component approach, clean code becomes easier to maintain. When a new requirement arrives to display products in a different format, you simply create a new dumb component for the UI and add a function in the smart component to load it. Existing dumb components remain untouched, and only a unit test for the new function in the smart component is needed.
Final step in the smart-dumb component pattern is for the smart component to handle events emitted by the dumb component. For example, when the user clicks the Details button in the ProductGrid (dumb) component, it emits the selected productid as payload. The smart component receives that productid and acts on it for task such as navigation, data fetch, etc.
To emit the event from the ProductGrid component to its parent (the smart component), declare an output() property on it and add a method that calls .emit() to send the data to the parent ProductComponent.
productNavigate = output<string>();
navigate(id:string){
this.productNavigate.emit(id);
}
Then call the navigate() function in the button’s click handler, as shown below:
<td><button (click)="navigate(product.id)">details</button></td>
On the parent component, you can handle the child’s event in two ways:
EventEmitter/observable with subscribe()outputToObservable → toSignal) and read it reactivelyThe parent can read the event emitted by the dumb component using the classic subscribe() approach, as shown below.
async loadGridView() {
if (this.productRef) {
this.productRef.clear();
const { ProductGrid } = await import('../product-grid/product-grid');
const componentRef = this.productRef.createComponent(ProductGrid);
componentRef.setInput('products', this.products());
componentRef.instance.productNavigate.subscribe((id: string) => {
console.log("Selected Product ID:", id);
// Example: this.router.navigate(['/product', id]);
});
}
}
The parent can read the event as modern reactive way using signal as shown below.
productId?: Signal<string | undefined>;
async loadGridView() {
if (this.productRef) {
this.productRef.clear();
const { ProductGrid } = await import('../product-grid/product-grid');
const componentRef = this.productRef.createComponent(ProductGrid);
componentRef.setInput('products', this.products());
let a = outputToObservable(componentRef.instance.productNavigate);
this.productId = toSignal(a, { injector: this.injector });
console.log(this.productId());
effect(() => {
const id = this.productId?.();
if (id) {
console.log("Selected Product ID:", id);
// Example: this.router.navigate(['/product', id]);
}
}, { injector: this.injector });
}
}
Above, we are converting the ProductGrid output to an observable using outputToObservable. After that, converting that observable into a signal using toSignal, and then in effect reading the changes in the signal value.
The smart-dumb component pattern in Angular helps organize components by separating their responsibilities.
This approach makes it easier to reuse the same data or state across different UI layouts and keeps components more maintainable.
Thank you for reading. I hope this article was helpful.
Dhananjay Kumar is a well-known trainer and developer evangelist. He is the founder of NomadCoder, a company that focuses on creating job-ready developers through training in technologies such as Angular, Node.js, Python, .NET, Azure, GenAI and more. He is also the founder of ng-India, one of the world’s largest Angular communities. He lives in Gurgaon, India, and is currently writing his second book on Angular. You can reach out to him for training, evangelism and consulting opportunities.