Component Architecture: Container ve Presentational Component'ler, Composition ve Contract'lar
Container component veriyi ve yan etkileri bilir, presentational component yalnız input alıp output ile ne olduğunu bildirir; bu ayrım katı bir kural değil, test edilebilirlik ve yeniden kullanım için bir düşünme aracıdır. Bookstore'un ürün listesini üç component'e bölüyor, input adını değiştirmenin NG8002/NG8008 hatası verdiğini, content projection'ı ve presentational testin ne kadar hafif olduğunu gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22 ve TypeScript ile denenmiştir.
Önce Tahmin Et
Bookstore'un ürün listesi sayfası, Catalog'daki kitapları listeliyor: arama kutusu, "yalnız stokta olanlar" kutucuğu, her kitap için bir kart, kartta "Sold out" ya da "Only 3 left" etiketi ve sepete ekleme butonu. İlk yazıldığı hâli çalışıyor ve gayet kısa:
import { Component, computed, inject, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import type { Product } from '../product';
@Component({
selector: 'app-product-list-page-before',
imports: [CurrencyPipe],
template: `
<input placeholder="Search" (input)="query.set($any($event.target).value)" />
<label><input type="checkbox" (change)="inStockOnly.set($any($event.target).checked)" /> In stock</label>
@for (p of visible(); track p.id) {
<article class="card">
<h3>{{ p.title }}</h3>
<p>{{ p.author }} · {{ p.price | currency: 'EUR' }}</p>
@if (p.stock === 0) {
<span class="badge">Sold out</span>
} @else if (p.stock < 5) {
<span class="badge">Only {{ p.stock }} left</span>
}
<button [disabled]="p.stock === 0" (click)="cartCount.set(cartCount() + 1)">Add to cart</button>
</article>
}
<p>Cart: {{ cartCount() }}</p>
`,
})
export class ProductListPageBefore {
// Her şey tek yerde: HTTP, filtre, kart, sepet
private readonly products = toSignal(inject(HttpClient).get<Product[]>('/api/products'), { initialValue: [] });
protected readonly query = signal('');
protected readonly inStockOnly = signal(false);
protected readonly cartCount = signal(0);
protected readonly visible = computed(() =>
this.products().filter(p =>
p.title.toLowerCase().includes(this.query().toLowerCase()) && (!this.inStockOnly() || p.stock > 0),
),
);
}
İki soru. Birincisi: stoğu biten bir kitabın kartında "Sold out" yazdığını ve butonun pasif olduğunu test etmek istiyorsun. Bu testi yazmak için neleri ayağa kaldırman gerekir? İkincisi: aynı kartı istek listesi sayfasında, "Add to cart" yerine "Remove" butonuyla göstermen istendi. Kartın markup'ını kopyalamadan bunu nasıl yaparsın?
Container ve Presentational: Ayrımın Kökeni
İlk sorunun cevabı can sıkıcı: kart ayrı bir parça olmadığı için tek bir etiketi test etmek bütün sayfayı test etmek demek. HttpClient'ın test altyapısı, /api/products isteğine sahte bir cevap, sonra DOM'da (Document Object Model) doğru kartı bulmak. İkinci soru için de iyi bir cevap yok; kart, sayfanın template'inin içinde bir @for gövdesi.
Bu sıkıntıya verilen en bilinen cevap Dan Abramov'un 2015'te yazdığı "Presentational and Container Components" yazısı. Abramov React dünyasında yaygınlaşan "smart" ve "dumb" component ayrımını şöyle adlandırdı: container component'ler işlerin nasıl yürüdüğüyle ilgilenir (veriyi nereden alacağını, event'lere ne olacağını bilir), presentational component'ler işlerin nasıl göründüğüyle ilgilenir (veriyi input olarak alır, kullanıcı etkileşimini dışarı bildirir). Angular topluluğu aynı ayrımı input/output ile birebir uyguladı.
| Container | Presentational | |
|---|---|---|
| Bildiği şey | Service'ler, store, route parametreleri, HTTP (Hypertext Transfer Protocol) | Yalnız input'ları |
| Dışarıya söylediği | Genelde hiçbir şey; route ile açılır | Output'lar: ne olduğunu bildirir |
| State | Sayfanın state'ine sahip ya da onu bir service'ten okur | En fazla kendi UI (user interface) state'i (açık mı, hover mı) |
| Yeniden kullanım | Nadiren; bir sayfaya ya da akışa özel | Sık; farklı sayfalarda aynı parça |
| Test | Provider'lar ve sahte service'ler gerekir | Input ver, DOM'a ve output'a bak |
Önemli bir not: Abramov 2019'da yazısının başına bir güncelleme ekleyip bu ayrımı artık bu şekilde önermediğini, aynı ayrışmanın React'te hook'larla component'e bölmeden de yapılabildiğini, ama kod tabanına doğal oturuyorsa hâlâ işe yarayabileceğini söyledi. Bu notu katı bir kural yerine bir düşünme aracı olarak oku. Amaç her component'i iki kutudan birine koymak değil; veriyi ve yan etkileri bilen parçaları az ve belli tutmak, geri kalanını yalnız input ve output ile konuşan, kolay test edilen ve yeniden kullanılan parçalar olarak yazmak. Önceki yazıdaki OrderForm ile QuantityStepper tam olarak bu ilişkideydi.
Sayfayı Bölmek
Sayfayı üç component'e ve bir kural dosyasına ayırıyoruz. Önce paylaşılan tipler:
export interface Product {
readonly id: number;
readonly title: string;
readonly author: string;
readonly price: number;
readonly stock: number;
}
export interface ProductCriteria {
readonly query: string;
readonly inStockOnly: boolean;
}
Kart artık yalnız bir ürünü gösteriyor; HTTP'yi, sepeti, filtreyi bilmiyor:
import { Component, computed, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import type { Product } from './product';
import { stockLabel } from './product-rules';
@Component({
selector: 'app-product-card',
imports: [CurrencyPipe],
template: `
<article class="card">
<h3>{{ product().title }}</h3>
<p>{{ product().author }} · {{ product().price | currency: 'EUR' }}</p>
@if (label(); as text) {
<span class="badge">{{ text }}</span>
}
<!-- Butonlara kartı kullanan karar verir -->
<ng-content select="[cardActions]" />
</article>
`,
})
export class ProductCard {
readonly product = input.required<Product>();
protected readonly label = computed(() => stockLabel(this.product().stock));
}
Filtre, kriterleri input olarak alıyor ve değişikliği yeni bir kriter object'i olarak bildiriyor:
import { Component, input, output } from '@angular/core';
import type { ProductCriteria } from './product';
@Component({
selector: 'app-product-filter',
template: `
<input placeholder="Search" [value]="criteria().query" (input)="changeQuery($event)" />
<label>
<input type="checkbox" [checked]="criteria().inStockOnly" (change)="toggleInStock($event)" />
In stock
</label>
`,
})
export class ProductFilter {
readonly criteria = input.required<ProductCriteria>();
readonly criteriaChange = output<ProductCriteria>();
protected changeQuery(event: Event): void {
const query = (event.target as HTMLInputElement).value;
this.criteriaChange.emit({ ...this.criteria(), query });
}
protected toggleInStock(event: Event): void {
const inStockOnly = (event.target as HTMLInputElement).checked;
this.criteriaChange.emit({ ...this.criteria(), inStockOnly });
}
}
Container ise bütün parçaları bir araya getiriyor. Veriyi nereden alacağını (CatalogApi), sepete eklemenin ne demek olduğunu (CartStore) ve filtre kriterlerinin state'ini bilen tek component bu:
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CartStore } from './cart-store';
import { CatalogApi } from './catalog-api';
import type { ProductCriteria } from './product';
import { ProductCard } from './product-card';
import { ProductFilter } from './product-filter';
import { filterProducts } from './product-rules';
@Component({
selector: 'app-product-list-page',
imports: [ProductCard, ProductFilter],
template: `
<app-product-filter [criteria]="criteria()" (criteriaChange)="criteria.set($event)" />
@for (product of visible(); track product.id) {
<app-product-card [product]="product">
<button cardActions [disabled]="product.stock === 0" (click)="cart.add(product)">Add to cart</button>
</app-product-card>
}
<p class="cart">Cart: {{ cart.count() }}</p>
`,
})
export class ProductListPage {
// Container: veriyi nereden alacağını ve event'lere ne olacağını bilir
protected readonly cart = inject(CartStore);
private readonly products = toSignal(inject(CatalogApi).getProducts(), { initialValue: [] });
protected readonly criteria = signal<ProductCriteria>({ query: '', inStockOnly: false });
protected readonly visible = computed(() => filterProducts(this.products(), this.criteria()));
}

Akışın yönü önceki iki yazıdaki ile aynı: kriterler ve ürünler input ile aşağı iniyor, kullanıcının yaptığı her şey output ya da event olarak container'a çıkıyor. Container'ın template'i artık sayfanın iskeletini okumaya yetiyor; kartın neye benzediğini görmek istersen ProductCard'a gidiyorsun.
Component'in Public API'si Bir Contract'tır
Bir component'in input'ları ve output'ları, onu kullanan herkesin dayandığı bir contract. input.required ile tanımlanan product, "bu kart ürün olmadan anlamsız" diyor; derleyici bunu kullanan her template'te kontrol ediyor. Bir gün biri input'un adını daha "genel" olsun diye item yaparsa:
$ npx ng build
✘ [ERROR] NG8002: Can't bind to 'product' since it isn't a known property of 'app-product-card'.
src/app/p08/product-list-page.ts:16:30
✘ [ERROR] NG8008: Required input 'item' from component ProductCard must be specified.
src/app/p08/product-list-page.ts:16:13
✘ [ERROR] NG8002: Can't bind to 'product' since it isn't a known property of 'app-product-card'.
src/app/p08/wishlist-page.ts:10:30
✘ [ERROR] NG8008: Required input 'item' from component ProductCard must be specified.
src/app/p08/wishlist-page.ts:10:13
Derleyici iki ayrı hata veriyor: product adlı bir input artık yok (NG8002) ve zorunlu item input'u verilmemiş (NG8008). Hatalar kartın kendisinde değil, onu kullanan iki sayfada çıkıyor. Contract'ı değiştiren, tüketicilerini kırıyor; tıpkı bir C# interface'indeki method imzasını değiştirmek gibi. Bu derleme hatası iyi bir şey: Angular 22'de strictTemplates varsayılan olarak açık ve bu tür kırılmaları runtime'a bırakmıyor.
Contract'ı tasarlarken birkaç soru işe yarıyor. Input bir domain object'i mi (Product), yoksa kartın gerçekten kullandığı alanlar mı? Domain object'i vermek kullanımı kolaylaştırır ama kartı Product'ın şekline bağlar; tek tek alanlar ise kartı farklı tiplerle kullanılabilir yapar ama input sayısını artırır. Output'lar ne olduğunu mu söylüyor (criteriaChange), yoksa parent'a ne yapacağını mı dikte ediyor? Ve en önemlisi: bu component'i birden fazla yerde kullanacak mısın? Tek bir sayfanın içindeki küçük bir parçanın contract'ı ile ekipler arasında paylaşılan bir library component'inin contract'ı aynı özeni istemez.
Content Projection ve Composition
İkinci soruya dönelim: aynı kart, istek listesinde başka bir butonla. İlk akla gelen çözüm karta boolean input'lar eklemek:
import { Component, input, output } from '@angular/core';
import type { Product } from '../product';
// Kötü örnek: her yeni kullanım yeri yeni bir boolean input demek
@Component({
selector: 'app-product-card-flags',
template: `
<h3>{{ product().title }}</h3>
@if (showAddButton()) {
<button [disabled]="product().stock === 0" (click)="add.emit(product())">{{ addLabel() }}</button>
}
@if (showRemoveButton()) {
<button (click)="remove.emit(product().id)">Remove</button>
}
`,
})
export class ProductCardFlags {
readonly product = input.required<Product>();
readonly showAddButton = input(true);
readonly showRemoveButton = input(false);
readonly addLabel = input('Add to cart');
readonly add = output<Product>();
readonly remove = output<number>();
}
Bu yaklaşım ilk kullanımda masum görünür; üçüncü sayfada showCompareButton, dördüncüde compact eklenir ve kart, kullanıldığı her yerin bilgisini taşıyan bir switch'e dönüşür. SOLID (Robert C. Martin'in derlediği beş tasarım prensibinin baş harfleri) ve Design Principles serisindeki Composition over Inheritance fikrinin component dünyasındaki karşılığı content projection: kart kendi iskeletini tanımlar, değişen kısmı kullanan tarafa bırakır. ProductCard'daki <ng-content select="[cardActions]" /> satırı tam bunu yapıyor. Liste sayfası oraya "Add to cart" koyuyor, istek listesi sayfası başka bir şey:
import { Component, signal } from '@angular/core';
import type { Product } from './product';
import { ProductCard } from './product-card';
@Component({
selector: 'app-wishlist-page',
imports: [ProductCard],
template: `
@for (product of wishlist(); track product.id) {
<app-product-card [product]="product">
<!-- Aynı kart, farklı aksiyon -->
<button cardActions (click)="remove(product.id)">Remove</button>
</app-product-card>
}
`,
})
export class WishlistPage {
protected readonly wishlist = signal<readonly Product[]>([
{ id: 3, title: 'Dracula', author: 'Bram Stoker', price: 11, stock: 12 },
]);
protected remove(id: number): void {
this.wishlist.update(list => list.filter(p => p.id !== id));
}
}
Kartın kodu değişmedi; yeni bir kullanım yeri yeni bir input eklemek anlamına gelmiyor. Bedeli şu: kart, projeksiyonla gelen butonun neye benzediği üzerinde kontrolü bırakıyor. Tutarlılık istiyorsan butonları da küçük presentational component'ler hâline getirip bir library'de toplayabilirsin. Content projection'ın temelleri Angular serisindeki Component İletişimi yazısında; buradaki mesele, kartın neyi bilip neyi dışarıya bıraktığı.
Template-Only Üyeler İçin protected
Angular Style Guide, yalnız template'ten okunan üyeler için protected kullanmayı öneriyor. ProductCard'da label bu yüzden protected; product input olduğu için public. Böylece class'ın görünürlüğü üç katmanlı bir mesaj veriyor: public üyeler component'in contract'ı (input, output), protected üyeler template'in ihtiyacı, private üyeler class'ın iç işleri.
Merak edip label'ı private yaptık ve ng build çalıştırdık: Angular 22.2'de build hatasız tamamlandı. Eski sürümlerde AOT (ahead-of-time) derleme template'ten private bir üyeye erişime izin vermiyordu; bugün derleyici bunu engellemiyor. Yani protected önerisi bir derleyici kuralı değil, okura verilen bir sinyal: başka bir component'ten ya da testten fixture.componentInstance.label'a uzanmaya çalışan, TypeScript'in uyarısıyla karşılaşır ve bunun contract'ın parçası olmadığını anlar.
Mantığı UI'dan Çıkarmak
Style Guide'ın bir başka önerisi, component ve directive içindeki kodun genelde ekrandaki UI ile ilgili olması; UI'dan bağımsız anlamlı bir kod parçası varsa ayrı bir dosyaya taşınması. Filtreleme kuralı ve stok etiketi bu türden. İkisini Angular'a hiç dokunmayan iki fonksiyona çevirdik:
import type { Product, ProductCriteria } from './product';
// UI'dan bağımsız kurallar: Angular'a ihtiyaçları yok
export function filterProducts(products: readonly Product[], criteria: ProductCriteria): readonly Product[] {
const query = criteria.query.trim().toLowerCase();
return products.filter(
p => (query === '' || p.title.toLowerCase().includes(query)) && (!criteria.inStockOnly || p.stock > 0),
);
}
export function stockLabel(stock: number): string | null {
if (stock === 0) return 'Sold out';
return stock < 5 ? `Only ${stock} left` : null;
}
import { filterProducts, stockLabel } from './product-rules';
import type { Product } from './product';
const products: Product[] = [
{ id: 1, title: 'The Hobbit', author: 'J. R. R. Tolkien', price: 14, stock: 3 },
{ id: 2, title: 'Emma', author: 'Jane Austen', price: 9.5, stock: 0 },
{ id: 3, title: 'Dracula', author: 'Bram Stoker', price: 11, stock: 12 },
];
describe('product rules', () => {
it('filters by title and stock', () => {
expect(filterProducts(products, { query: ' HOBBIT ', inStockOnly: false }).map(p => p.id)).toEqual([1]);
expect(filterProducts(products, { query: '', inStockOnly: true }).map(p => p.id)).toEqual([1, 3]);
});
it('labels low and empty stock', () => {
expect([0, 3, 12].map(stockLabel)).toEqual(['Sold out', 'Only 3 left', null]);
});
});
Bu testler TestBed kullanmıyor, component yaratmıyor, DOM'a bakmıyor. Arama kutusuna boşluklu ve büyük harfli ' HOBBIT ' yazıldığında doğru sonucun gelmesi ya da stok 5'in altına indiğinde etiketin değişmesi gibi kurallar burada, milisaniyeler içinde test ediliyor. Kart ve container bu fonksiyonları yalnız çağırıyor.
Presentational Test'in Kolaylığı
Açılıştaki ilk soruya bölünmüş hâliyle dönelim. "Sold out" etiketini test etmek için artık yalnız kartı yaratıp bir input vermek yetiyor:
import { TestBed } from '@angular/core/testing';
import { ProductCard } from './product-card';
describe('ProductCard', () => {
it('shows a sold-out badge', async () => {
const fixture = TestBed.createComponent(ProductCard);
// Presentational test: HTTP yok, service yok, yalnız input
fixture.componentRef.setInput('product', { id: 2, title: 'Emma', author: 'Jane Austen', price: 9.5, stock: 0 });
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.badge').textContent).toBe('Sold out');
});
});
Filtrenin testi, component'in input'unu değiştirmediğini, yeni bir kriteri output ile bildirdiğini doğruluyor:
import { TestBed } from '@angular/core/testing';
import type { ProductCriteria } from './product';
import { ProductFilter } from './product-filter';
describe('ProductFilter', () => {
it('emits new criteria instead of changing its input', async () => {
const fixture = TestBed.createComponent(ProductFilter);
fixture.componentRef.setInput('criteria', { query: '', inStockOnly: true });
const emitted: ProductCriteria[] = [];
fixture.componentInstance.criteriaChange.subscribe(c => emitted.push(c));
await fixture.whenStable();
const search: HTMLInputElement = fixture.nativeElement.querySelector('input');
search.value = 'domain';
search.dispatchEvent(new Event('input'));
expect(emitted).toEqual([{ query: 'domain', inStockOnly: true }]);
});
});
Container'ın testi ise hâlâ eskisi kadar ağır; HTTP altyapısı, sahte cevap, birden fazla render:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ProductListPage } from './product-list-page';
describe('ProductListPage', () => {
it('disables the button of a sold-out product', async () => {
// Container test: HTTP altyapısı ve sahte cevap gerekiyor
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
const fixture = TestBed.createComponent(ProductListPage);
await fixture.whenStable();
TestBed.inject(HttpTestingController).expectOne('/api/products').flush([
{ id: 1, title: 'The Hobbit', author: 'J. R. R. Tolkien', price: 14, stock: 3 },
{ id: 2, title: 'Emma', author: 'Jane Austen', price: 9.5, stock: 0 },
]);
await fixture.whenStable();
const buttons: HTMLButtonElement[] = Array.from(fixture.nativeElement.querySelectorAll('button'));
buttons.forEach(b => b.click());
await fixture.whenStable();
expect(buttons.map(b => b.disabled)).toEqual([false, true]);
expect(fixture.nativeElement.querySelector('.cart').textContent).toBe('Cart: 1');
});
});
$ npx ng test --watch=false --reporters=verbose --include src/app/p08
✓ |bookstore| src/app/p08/product-rules.spec.ts > product rules > filters by title and stock
✓ |bookstore| src/app/p08/product-rules.spec.ts > product rules > labels low and empty stock
✓ |bookstore| src/app/p08/product-card.spec.ts > ProductCard > shows a sold-out badge
✓ |bookstore| src/app/p08/product-filter.spec.ts > ProductFilter > emits new criteria instead of changing its input
✓ |bookstore| src/app/p08/product-list-page.spec.ts > ProductListPage > disables the button of a sold-out product
Tests 5 passed (5)
Bu bir kusur değil, ayrımın amacı. Ağır test sayfa başına bir ya da iki tane, bağlantıların doğru kurulduğunu doğruluyor; kuralların ve görünümün ayrıntısı hafif testlere dağılıyor. Component testinin kendisi (TestBed, harness'ler, Vitest ile Angular test stratejisi) ilerideki Test ve Kalite kategorisindeki Frontend Testleri serisinin konusu.
Component Boyutu ve Bölme Sinyalleri
Bir component'i ne zaman bölmeli? Satır sayısı tek başına iyi bir ölçü değil; aşağıdaki sinyaller daha güvenilir:
- Testini yazmak için ilgisiz provider'lar gerekiyor: bir etiketi test etmek için HTTP mock'lamak gibi.
- Değişmek için birden çok sebebi var: SOLID serisindeki SRP (Single Responsibility Principle) yazısının "tek actor" ölçüsüyle, tasarımcının, ürün sahibinin ve backend ekibinin istekleri aynı dosyaya düşüyor.
- Template'te aynı markup bloğu tekrarlanıyor ya da bir
@forgövdesi kendi başına bir şey anlatıyor. - Boolean input'lar çoğalıyor ve her biri başka bir kullanım yerini temsil ediyor.
- Bir template ifadesi okunamayacak kadar uzadı; Style Guide burada mantığı TypeScript'e, tercihen bir
computed'a taşımayı öneriyor.
Ters yön de aynı derecede gerçek. Aşırı bölünmüş bir ağaçta bir değer dört seviye boyunca yalnız aktarılmak için input olarak taşınır (React dünyasında "prop drilling" deniyor), tek bir yerde kullanılan ve hiçbir şey yapmayan sarmalayıcı component'ler çoğalır ve bir değişikliği takip etmek için altı dosya açarsın. Bölmenin karşılığı ya test kolaylığı ya yeniden kullanım olmalı; ikisi de yoksa bölme.
Sık Düşülen Tuzaklar
Presentational component'e service inject etmek. "Sadece bir tane" diye karta CartStore inject etmek, onu her kullanıldığı yerde o store'a bağlar ve testini container testi kadar ağırlaştırır. Kartın bir şeyi değiştirmesi gerekiyorsa output ya da projeksiyonla gelen içerik yeterli.
Her şeyi container yapmak ya da hiçbir şeyi container yapmamak. Her component'in kendi verisini çektiği bir ağaç, aynı HTTP isteğini dört kez yapar; her şeyin tek bir dev container'dan aktarıldığı ağaç ise input aktarmaktan ibaret olur. Container'lar genelde route seviyesinde ya da büyük bölümlerin başında durur.
Output yerine callback input'u vermek. input<() => void>() ile parent'tan fonksiyon almak çalışır ama akışı gizler: template'te (event) görünmez ve child, parent'ın bir method'unu çağırdığını bilir hâle gelir.
Input'ları contract olarak görmemek. Paylaşılan bir component'in input adını, tipini ya da zorunluluğunu değiştirmek, kullanan herkesin derlemesini kırar. Paylaşılan component'lerde değişikliği önce yeni bir input ekleyip eskiyi bir süre yaşatarak yap.
Bölünmüş component'leri yine birbirine bağlamak. Kartın, filtrenin seçimini bir service üzerinden okuması, iki presentational component'i görünmez bir kanalla birbirine bağlar. Aralarındaki ilişkiyi container kurmalı.
Küçük Bir Deneme
★☆☆ ProductCard'a fiyatın yanında indirimli fiyatı gösterme özelliği ekle. Bunu kartın kendi input'u olarak mı, yoksa projeksiyonla gelen bir slot olarak mı yaparsın? Kararını kartın kaç yerde kullanıldığına göre gerekçelendir.
★★☆ ProductFilter'a "en düşük fiyat" alanı ekle. Kural filterProducts'a gitsin, önce onun testini yaz; component testine yalnız yeni alanın output'a yansıdığını doğrulayan bir satır ekle.
★★★ Açılıştaki ProductListPageBefore'u adım adım bu yazıdaki yapıya çevir ve her adımda testleri çalıştır: önce kuralları fonksiyonlara, sonra kartı, sonra filtreyi çıkar. Hangi adımda container testinin kapsadığı şeyler azaldı?
Bu yazıda bir sayfanın içini böldük: veriyi bilen container, yalnız input ve output ile konuşan presentational component'ler ve UI'dan bağımsız kurallar. Serinin son yazısında bir seviye yukarı çıkıyoruz: onlarca sayfası ve birkaç ekibi olan bir Angular uygulamasında feature'lar arasındaki sınırları nasıl çizeriz, facade service component'i state ve HTTP detayından nasıl ayırır ve bu sınırları lint ile nasıl zorlarız?