Partial View, View Component ve Razor Component: MVC'de UI Parçalarını Yeniden Kullanmak
Verisi çağıranda olan tekrar eden HTML için partial view, her sayfada görünen ve verisini kendi getiren parça için view component, Blazor'a köprü ya da ileride interaktif olacak parça için Razor component kullan. Partial içinden DbContext çağırınca sorgu sayısının 3'ten 7'ye çıktığını ölçüyor, sepet özeti view component'ini, controller'dan ViewComponent() dönmeyi ve <component> tag helper'ının Static ve ServerPrerendered modlarını görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ve Microsoft.EntityFrameworkCore.Sqlite 10.0.14 ile derlenip denenmiştir.
Önce Tahmin Et
Katalog sayfası dört kitap kartı gösteriyor ve her kart aynı partial view'dan geliyor. Layout'un sağ üstünde de sepet özetini gösteren bir view component var. Bu sayfanın tek bir isteği şu an veritabanına üç sorgu atıyor. Bir gün biri kartlara stok bilgisi eklemek istiyor ve en kısa yolu seçiyor: partial'ın içine @inject BookstoreDbContext Db yazıp stoku orada sorguluyor. Aynı istek artık kaç sorgu atar? Bir de şunu tahmin et: layout'taki sepet özeti mi önce çalışır, sayfanın gövdesindeki kartlar mı?
Partial View: Aynı HTML'i İki Yerde Kullanmak
Kitap kartı hem katalogda hem ana sayfadaki "Featured" kutusunda görünüyor. Aynı HTML'i (HyperText Markup Language) iki view'a kopyalamak yerine onu Views/Shared/_BookCard.cshtml adlı bir partial view'a taşıdık:
@model BookListItemViewModel
<div class="card mb-2">
<div class="card-body">
<h5 class="card-title">
<a asp-area="" asp-controller="Books" asp-action="Details" asp-route-id="@Model.Id">@Model.Title</a>
</h5>
<p class="card-text">@Model.AuthorName · @Model.Price.ToString("0.00") TL</p>
</div>
</div>
Partial, layout'u olmayan bir view'dır ve kendi @model'i vardır. Başındaki alt çizgi bir kural değil, bir gelenek: "bu dosya tek başına sayfa değildir" demek. Katalog onu <partial> tag helper'ıyla çağırıyor:
@model BookCatalogViewModel
@{
ViewData["Title"] = "Books";
}
<h1>Books</h1>
@foreach (BookListItemViewModel book in Model.Books)
{
<partial name="_BookCard" model="book" />
}
<p>Page @Model.Page of @Model.TotalPages</p>
Ana sayfa da aynı partial'ı HTML Helper (@Html.*) biçimiyle çağırıyor. İkisi aynı işi yapar; tag helper biçimi HTML'e daha çok benzediği ve model attribute'u tip kontrolünden geçtiği için bugün önerilen o:
@model HomeViewModel
@{
ViewData["Title"] = "Home";
}
<h1>Bookstore</h1>
<h2>Featured</h2>
@foreach (BookListItemViewModel book in Model.Featured)
{
@await Html.PartialAsync("_BookCard", book)
}
<p><a asp-controller="Books" asp-action="Index">Browse the catalog</a></p>
Ana sayfanın verisini controller hazırlıyor; "öne çıkan kitaplar" sorgusu IBookCatalog'a eklendi:
public class HomeController(IBookCatalog catalog) : Controller
{
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
IReadOnlyList<BookListItemViewModel> featured = await catalog.GetFeaturedAsync(cancellationToken);
return View(new HomeViewModel(featured));
}
}
GET / yanıtında partial'ın ürettiği HTML:
<h2>Featured</h2>
<div class="card mb-2">
<div class="card-body">
<h5 class="card-title">
<a href="/Books/Details/5">Stars Over Ankara</a>
</h5>
<p class="card-text">Grace Hill · 295.00 TL</p>
</div>
</div>
<div class="card mb-2">
<div class="card-body">
<h5 class="card-title">
<a href="/Books/Details/1">The Pragmatic Path</a>
</h5>
<p class="card-text">Ada Lane · 420.00 TL</p>
</div>
</div>
Partial'ın adı da view gibi aranır. Adı yanlış yazdığımda (_BookCrd) sayfa 500 döndü ve log'da arama sırası vardı:
System.InvalidOperationException: The partial view '_BookCrd' was not found. The following locations were searched:
/Views/Books/_BookCrd.cshtml
/Views/Shared/_BookCrd.cshtml
Önce çağıran view'ın klasörü, sonra Shared. Yalnız bir controller'ın kullandığı partial'ı Views/Books altına koyarsan ortak klasör kalabalıklaşmaz. Partial'a model vermezsen çağıranın modelini alır; ViewData'nın bir kopyasını da her durumda alır. Bu kolaylık, partial'ın hangi veriye bağımlı olduğunu gizleyebileceği için ben her zaman açıkça model veriyorum. Partial'ın kendisi zekâ içermemeli: aldığı modeli gösterir, o kadar.
Partial İçinden Servis Çağırmanın Bedeli
Tahminin ilk kısmına gelelim. Önce mevcut durumu ölçtüm: appsettings.Development.json'da Microsoft.EntityFrameworkCore.Database.Command kategorisini Information'a açıp GET /Books isteğinin log'undaki Executed DbCommand satırlarını saydım. Sonuç 3: sayfa sayısı için COUNT, kitap listesi ve sepet. Sonra kartlara stok eklemek için en kısa yolu denedim:
@model BookListItemViewModel
@inject Bookstore.Web.Data.BookstoreDbContext Db
@{
int stock = Db.Books.Where(b => b.Id == Model.Id).Select(b => b.Stock).First();
}
<div class="card mb-2">
<div class="card-body">
<h5 class="card-title">
<a asp-area="" asp-controller="Books" asp-action="Details" asp-route-id="@Model.Id">@Model.Title</a>
</h5>
<p class="card-text">@Model.AuthorName · @Model.Price.ToString("0.00") TL · @stock in stock</p>
</div>
</div>
Sayfa çalıştı, kartlarda "0 in stock", "7 in stock" yazıyordu. Aynı isteğin sorgu sayısı 3'ten 7'ye çıktı. Log'daki sıra (satırlar kısaltıldı):
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeou
Executed DbCommand (0ms) [Parameters=[@p1='?' (DbType = Int32), @p='?' (Db
Executed DbCommand (0ms) [Parameters=[@Model_Id='?' (DbType = Int32)], Com
Executed DbCommand (0ms) [Parameters=[@Model_Id='?' (DbType = Int32)], Com
Executed DbCommand (0ms) [Parameters=[@Model_Id='?' (DbType = Int32)], Com
Executed DbCommand (0ms) [Parameters=[@Model_Id='?' (DbType = Int32)], Com
Executed DbCommand (0ms) [Parameters=[@ids1='?' (DbType = Int32), @ids2='?
Ortadaki dört satırın her biri şu sorgu:
SELECT "b"."Stock"
FROM "Books" AS "b"
WHERE "b"."Id" = @Model_Id
LIMIT 1
Klasik N+1: liste için bir sorgu, her satır için bir sorgu daha. Dört kartta fark edilmez; sayfa boyutu 50 olunca 50 ek sorgu olur ve bunların hepsi view render edilirken, senkron (First(), FirstAsync() değil) ve thread'i bloklayarak çalışır. Controller'ı okuyan biri bu sorguların varlığından haberdar bile olmaz. Doğru çözüm, stoku listenin sorgusuna eklemek: BookListItemViewModel'e bir Stock alanı ve projeksiyona b.Stock. Tek sorgu, sıfır sürpriz. Ben bu deneyi geri aldım; partial'ımız yine yalnız modelini gösteriyor.
Tahminin ikinci kısmının cevabı da aynı log'da: sepetin sorgusu (@ids1 parametreli olan) en sonda. Razor önce view'ın gövdesini render eder, layout'u sonra. Layout'taki view component, sayfa gövdesindeki her şeyden sonra çalışır.
View Component: Kendi Verisini Getiren Parça
Sepet özeti her sayfada, layout'ta görünüyor. Partial ile yapmaya kalksan, her controller'ın her action'ı sepet verisini ViewModel'ine koymak zorunda kalırdı. View component bu iş için var: kendi bağımlılıklarını DI (Dependency Injection) ile alan, kendi verisini getiren ve kendi view'ını render eden küçük bir controller gibi düşün. Sepet service'i:
public interface ICartService
{
Task<CartSummaryViewModel> GetSummaryAsync(CancellationToken cancellationToken = default);
}
// Demo sepet: kitap id'leri "1-4-4" gibi bir "cart" cookie'sinde tutuluyor
public class CookieCartService(IHttpContextAccessor httpContextAccessor, BookstoreDbContext db) : ICartService
{
public async Task<CartSummaryViewModel> GetSummaryAsync(CancellationToken cancellationToken = default)
{
string? raw = httpContextAccessor.HttpContext?.Request.Cookies["cart"];
List<int> ids = (raw ?? "")
.Split('-', StringSplitOptions.RemoveEmptyEntries)
.Select(x => int.TryParse(x, out int id) ? id : 0)
.Where(id => id > 0)
.ToList();
if (ids.Count == 0)
{
return new CartSummaryViewModel(0, 0m);
}
Dictionary<int, decimal> prices = await db.Books.AsNoTracking()
.Where(b => ids.Contains(b.Id))
.Select(b => new { b.Id, b.Price })
.ToDictionaryAsync(b => b.Id, b => b.Price, cancellationToken);
decimal total = ids.Where(prices.ContainsKey).Sum(id => prices[id]);
return new CartSummaryViewModel(ids.Count(prices.ContainsKey), total);
}
}
View component'in kendisi:
using Bookstore.Web.Services;
using Bookstore.Web.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace Bookstore.Web.ViewComponents;
public class CartSummaryViewComponent(ICartService cart) : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync(bool compact = false)
{
CartSummaryViewModel summary = await cart.GetSummaryAsync(HttpContext.RequestAborted);
return compact ? View("Compact", summary) : View(summary);
}
}
Kurallar: class ViewComponent'ten türer (ya da adı ViewComponent ile biter veya [ViewComponent] attribute'u taşır), tek bir InvokeAsync (ya da senkron Invoke) method'u vardır ve bu method'un parametreleri çağıranın verdiği argümanlardır. Component'in adı class adından ViewComponent soneki atılarak çıkar: CartSummary. View(summary) çağrısı Default adlı view'ı, View("Compact", summary) da Compact'ı arar; arama yeri Views/<Controller>/Components/CartSummary/ ve Views/Shared/Components/CartSummary/:
@model CartSummaryViewModel
@if (Model.ItemCount == 0)
{
<span class="navbar-text">Cart is empty</span>
}
else
{
<span class="navbar-text">Cart: @Model.ItemCount items · @Model.Total.ToString("0.00") TL</span>
}
@model CartSummaryViewModel
<span class="badge bg-secondary">@Model.ItemCount</span>
Layout'ta çağırmanın iki yolu var. @await Component.InvokeAsync("CartSummary", new { compact = false }) string adla ve anonim object'le çalışır; adı yanlış yazarsan hatayı runtime'da alırsın. Tag helper biçimi ise kebab-case bir etiket: <vc:cart-summary />. Bunun çalışması için uygulamanın kendi assembly'sini tag helper kaynağı olarak eklemen gerekiyor; _ViewImports.cshtml'e bir satır:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Bookstore.Web
</ul>
<vc:cart-summary />
</div>
Tag helper biçiminde parametre değerleri C# ifadesi olarak derlenir: <vc:cart-summary compact="maybe" /> yazdığımda build error CS0103: The name 'maybe' does not exist in the current context ile kırıldı. Ama sınırı da var: parametre adını yanlış yazdığımda (compactt="true") ne hata ne uyarı aldım; bilinmeyen attribute sessizce yok sayıldı ve component varsayılan değerle çalıştı. Sepette 1, 4 ve 4 numaralı kitaplar varken (curl --cookie "cart=1-4-4") layout'un ürettiği gerçek HTML:
<span class="navbar-text">Cart: 3 items · 1180.00 TL</span>
420 + 380 + 380 = 1180. Hiçbir controller sepetten haberdar değil; BooksController, HomeController ve ileride yazacağımız her controller bu özeti bedavaya alıyor.
View Component'i Controller'dan Döndürmek
View component'i yalnız view'dan değil, bir action'dan da çağırabilirsin. Sepete ürün ekleyen bir JavaScript isteğinden sonra rozeti yenilemek için yalnız parçayı dönen bir endpoint:
using Microsoft.AspNetCore.Mvc;
namespace Bookstore.Web.Controllers;
public class CartController : Controller
{
// Sepet rozetini sayfayı yenilemeden güncellemek için yalnız parçayı döner
public IActionResult Summary(bool compact = true) =>
ViewComponent("CartSummary", new { compact });
}
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Sun, 08 Nov 2026 02:16:58 GMT
Server: Kestrel
Transfer-Encoding: chunked
<span class="badge bg-secondary">3</span>
Layout yok, yalnız component'in HTML'i. Aynı fikri PartialView() ile de yapabilirsin; fark, verinin kim tarafından hazırlandığı. PartialView("_BookCard", model) modeli action'ın hazırlamasını bekler, ViewComponent(...) ise veriyi kendisi getirir. Bu teknik, bu serinin 11. yazısındaki htmx örneklerinin temeli olacak.
Razor Component'i MVC View'ında Render Etmek
Üçüncü seçenek Blazor dünyasından gelen Razor component. .razor uzantılı, parametrelerini [Parameter] ile alan bir component'i MVC (Model-View-Controller) view'ının içinde render edebilirsin. Kitap puanını yıldızlarla gösteren küçük bir component:
<span class="rating" title="@Stars out of 5">
@for (int i = 1; i <= 5; i++)
{
<span>@(i <= Stars ? "★" : "☆")</span>
}
</span>
@code {
[Parameter]
public int Stars { get; set; }
}
Components/BookRating.razor dosyasının yanına, component'lerin kullandığı namespace'ler için bir Components/_Imports.razor ekledik:
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
Detay view'ında <component> tag helper'ı ile çağırıyoruz:
<component type="typeof(Bookstore.Web.Components.BookRating)" render-mode="Static" param-Stars="4" />
Program.cs'e hiçbir şey eklemedik; AddControllersWithViews() statik render için yeterli. Gerçek çıktı:
<span class="rating" title="4 out of 5"><span>★</span><span>★</span><span>★</span><span>★</span><span>☆</span></span>
render-mode="Static" component'i bir kez render eder ve düz HTML bırakır; bu modda bir partial'dan farkı yok, yalnızca yazım biçimi Razor component. Asıl değer başka modlarda: render-mode="ServerPrerendered" yazdığımda sayfa yine 200 döndü, HTML aynı, ama etrafında bir yorum bloğu belirdi:
<!--Blazor:{"type":"server","prerenderId":"5d17566d08a34dfc9997629a254014cc", ... ,"descriptor":"CfDJ8EINq/wm4TFC..."}--><span class="rating" title="4 out of 5">...</span><!--Blazor:{"prerenderId":"5d17566d08a34dfc9997629a254014cc"}-->
Bu işaretler, tarayıcıdaki Blazor script'inin component'i bulup SignalR bağlantısı üzerinden interaktif hâle getirmesi için. Uygulamada Blazor Server service'leri, hub endpoint'i ve blazor.server.js olmadığı için hiçbir şey olmuyor: ne hata, ne uyarı, yalnızca çalışmayan işaretler. Interaktif mod için gereken kurulum ilerideki Blazor serisinin konusu; MVC tarafında bilmen gereken, Static modun ek kurulum istemediği ve diğer modların istediği.
Hangisi Ne Zaman?
| Soru | Partial view | View component | Razor component |
|---|---|---|---|
| Verisini kim hazırlar? | Çağıran view/action | Kendisi (DI ile service alır) | Çağıran, parametre olarak |
| Kendi mantığı | Yok, yalnız gösterim | InvokeAsync içinde | @code bloğunda |
| Tipik kullanım | Liste satırı, kart, form parçası | Menü, sepet özeti, "son yazılar", bildirim sayısı | Blazor'a geçişte paylaşılan UI (User Interface), ileride interaktif olacak parça |
| Controller'dan döndürme | PartialView() | ViewComponent() | Doğrudan değil |
| Ek kurulum | Yok | Tag helper için @addTagHelper *, Bookstore.Web | Static için yok, interaktif için Blazor |
Kendi kuralım: verisi çağıranın elinde olan tekrar eden HTML partial; her sayfada görünen ve verisini kendisi bulması gereken parça view component; ekip Blazor'a geçiyorsa ya da parçanın bir gün interaktif olacağı belliyse Razor component.
Üretimde Dikkat
Layout'taki bir view component her sayfa isteğinde çalışır. Sepet özetimiz her sayfaya bir sorgu ekliyor; bu ölçülebilir ve bilinçli bir maliyet olmalı. Çok sık okunan ve nadiren değişen içerik (kategori menüsü gibi) için component'in içinde IMemoryCache ya da view tarafında <cache> tag helper'ı kullanabilirsin; ikincisi bir sonraki yazıda.
View component içinde fırlayan bir exception bütün sayfayı 500'e çevirir. Sepet servisi çökerse kullanıcı katalogu da göremez. Kritik olmayan bir parça için InvokeAsync içinde hatayı yakalayıp log'layan ve boş bir view dönen bir yaklaşım, sayfanın geri kalanını korur. Bunu bilinçli yap; her hatayı yutan bir component sorunları gizler.
Son olarak, view component'lerde CancellationToken parametre olarak gelmez. HttpContext.RequestAborted'ı kullan; yukarıdaki kod bunu yapıyor.
Sık Düşülen Tuzaklar
Partial içinden servis ya da DbContext çağırmak. Yukarıda ölçtük: dört kartlık sayfada 4 ek sorgu, hepsi senkron ve controller'ın görüş alanının dışında. Partial modelini gösterir, veri getirmez.
View component'i partial gibi kullanmak. Verisi zaten action'da hazır olan bir parçayı view component yapıp, aynı veriyi component'in içinde tekrar sorgulamak çift iş.
@addTagHelper *, Bookstore.Web satırını unutmak. Satırı silip sayfayı açtığımda HTML'de sepet özeti yerine <vc:cart-summary b-v51vsz6my3 /> vardı: etiket hiçbir hata vermeden olduğu gibi yazıldı (layout'un CSS (Cascading Style Sheets) isolation attribute'u da üstüne eklendi) ve tarayıcı tanımadığı bu etiketi yok saydı; sepet özeti sessizce kayboldu. Assembly adı proje adından farklıysa (ör. <AssemblyName> değiştirilmişse) assembly'nin gerçek adını yazman gerekir.
Partial'a model vermemek. Partial çağıranın modelini alır; çağıran değişince partial derlenir ama yanlış tiple runtime'da patlar.
Küçük Bir Deneme
★☆☆ BookListItemViewModel'e int Stock ekle, BookCatalog.ListAsync'in projeksiyonunu güncelle ve kartta stoku göster. Entity Framework Core (EF Core) komut log'unda istek başına sorgu sayısını ölç: 3 mü kaldı?
★★☆ CategoryMenuViewComponent yaz: Genre enum'unun her değeri için katalogda o türden kaç kitap olduğunu göstersin ve layout'a ekle. Sonra sonucunu IMemoryCache ile 5 dakika cache'le ve sorgu sayısının ikinci istekte düştüğünü log'da gör.
★★★ CartController'a sepete ürün ekleyen bir POST action'ı yaz (cookie'yi güncellesin) ve ardından ViewComponent("CartSummary") dönsün. Tarayıcıda fetch ile çağırıp dönen HTML'i rozetin yerine koy. Antiforgery token'ı olmadan bu POST kabul edilmeli mi? Cevabı 6. yazıda vereceğiz.
Partial'larımızda ve layout'ta asp-controller, asp-action, <partial> ve <vc:...> gibi etiketler kullandık ama nasıl çalıştıklarını konuşmadık. Bir sonraki yazıda tag helper'ların içine bakıyoruz: yerleşik olanlar ne üretiyor, HTML Helper'dan farkları ne, ve kendi <status-badge> ile <secure-link> tag helper'larımızı nasıl yazarız.