Mert Özen Further With Every Line
Backend ASP.NET Core MVC and Razor Pages — #04

Partial Views, View Components and Razor Components: Reusing UI Pieces in MVC

Mert Özen Nov 8, 2026 11 min 3 views
Partial Views, View Components and Razor Components: Reusing UI Pieces in MVC

Use a partial view for repeated HTML whose data the caller already has, a view component for a fragment on every page that fetches its own data, and a Razor component as a bridge to Blazor or for a piece that will become interactive. We measure queries jumping from 3 to 7 when a partial calls DbContext, build a cart summary view component, return ViewComponent() from a controller and try the <component> tag helper's Static and ServerPrerendered modes.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402) and Microsoft.EntityFrameworkCore.Sqlite 10.0.14.

Guess First

The catalog page shows four book cards, and every card comes from the same partial view. In the top right of the layout there is also a view component showing the cart summary. Right now a single request to this page sends three queries to the database. One day someone wants to add stock information to the cards and picks the shortest route: they write @inject BookstoreDbContext Db inside the partial and query the stock there. How many queries does the same request send now? And guess this too: does the cart summary in the layout run first, or the cards in the page body?

Partial Views: Using the Same HTML in Two Places

The book card appears both in the catalog and in the "Featured" box on the home page. Instead of copying the same HTML (HyperText Markup Language) into two views, we moved it into a partial view named Views/Shared/_BookCard.cshtml:

@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>

A partial is a view without a layout, with its own @model. The leading underscore is not a rule but a convention meaning "this file is not a page on its own". The catalog calls it with the <partial> tag helper:

@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>

The home page calls the same partial in the HTML Helper (@Html.*) form. Both do the same job; the tag helper form is the one recommended today because it looks more like HTML and its model attribute is type checked:

@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>

The controller prepares the home page data; the "featured books" query was added to IBookCatalog:

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));
    }
}

The HTML produced by the partial in the GET / response:

<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>

A partial's name is looked up the same way as a view's. When I misspelled the name (_BookCrd), the page returned 500 and the log showed the search order:

System.InvalidOperationException: The partial view '_BookCrd' was not found. The following locations were searched:
/Views/Books/_BookCrd.cshtml
/Views/Shared/_BookCrd.cshtml

First the calling view's folder, then Shared. If you put a partial used by only one controller under Views/Books, the shared folder does not get crowded. If you do not pass a model to a partial, it gets the caller's model; it gets a copy of ViewData in every case. Because that convenience can hide what data the partial depends on, I always pass model explicitly. The partial itself should hold no intelligence: it displays the model it receives, nothing more.

The Cost of Calling a Service from a Partial

On to the first part of the guess. First I measured the current state: I raised the Microsoft.EntityFrameworkCore.Database.Command category to Information in appsettings.Development.json and counted the Executed DbCommand lines in the log of a GET /Books request. The result was 3: a COUNT for the page count, the book list, and the cart. Then I tried the shortest route to add stock to the cards:

@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>

The page worked, and the cards said "0 in stock" and "7 in stock". The number of queries for the same request went from 3 to 7. The order in the log (lines truncated):

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='?

Each of the four lines in the middle is this query:

SELECT "b"."Stock"
FROM "Books" AS "b"
WHERE "b"."Id" = @Model_Id
LIMIT 1

Classic N+1: one query for the list and one more for every row. With four cards nobody notices; with a page size of 50 it becomes 50 extra queries, all running while the view renders, synchronously (First(), not FirstAsync()) and blocking the thread. Someone reading the controller would not even know these queries exist. The right fix is to add the stock to the list query: a Stock field on BookListItemViewModel and b.Stock in the projection. One query, zero surprises. I rolled this experiment back; our partial once again only displays its model.

The answer to the second part of the guess is in the same log: the cart query (the one with the @ids1 parameters) comes last. Razor renders the view's body first and the layout afterwards. A view component in the layout runs after everything in the page body.

View Components: a Fragment That Fetches Its Own Data

The cart summary appears on every page, in the layout. If you tried to do it with a partial, every action of every controller would have to put cart data into its ViewModel. View components exist for exactly this: think of a small controller that takes its own dependencies through DI (Dependency Injection), fetches its own data and renders its own view. The cart service:

public interface ICartService
{
    Task<CartSummaryViewModel> GetSummaryAsync(CancellationToken cancellationToken = default);
}

// Demo cart: book ids are kept in a "cart" cookie such as "1-4-4"
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);
    }
}

The view component itself:

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);
    }
}

The rules: the class derives from ViewComponent (or its name ends with ViewComponent, or it carries the [ViewComponent] attribute), it has a single InvokeAsync (or synchronous Invoke) method, and that method's parameters are the arguments given by the caller. The component's name comes from the class name with the ViewComponent suffix removed: CartSummary. The View(summary) call looks for a view named Default, and View("Compact", summary) for Compact; the lookup locations are Views/<Controller>/Components/CartSummary/ and 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>

There are two ways to call it from the layout. @await Component.InvokeAsync("CartSummary", new { compact = false }) works with a string name and an anonymous object; if you misspell the name you get the error at runtime. The tag helper form is a kebab-case tag: <vc:cart-summary />. For it to work you need to add the application's own assembly as a tag helper source; one line in _ViewImports.cshtml:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Bookstore.Web
                </ul>
                <vc:cart-summary />
            </div>

In the tag helper form, parameter values are compiled as C# expressions: when I wrote <vc:cart-summary compact="maybe" />, the build broke with error CS0103: The name 'maybe' does not exist in the current context. It has a limit, though: when I misspelled the parameter name (compactt="true"), I got neither an error nor a warning; the unknown attribute was silently ignored and the component ran with its default value. With books 1, 4 and 4 in the cart (curl --cookie "cart=1-4-4"), the real HTML produced by the layout:

                        <span class="navbar-text">Cart: 3 items · 1180.00 TL</span>

420 + 380 + 380 = 1180. No controller knows about the cart; BooksController, HomeController and every controller we write later get this summary for free.

Returning a View Component from a Controller

You can call a view component not only from a view but from an action as well. An endpoint that returns only the fragment, to refresh the badge after a JavaScript request that adds an item to the cart:

using Microsoft.AspNetCore.Mvc;

namespace Bookstore.Web.Controllers;

public class CartController : Controller
{
    // Returns only the fragment, to refresh the cart badge without reloading the page
    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>

No layout, just the component's HTML. You can do the same with PartialView(); the difference is who prepares the data. PartialView("_BookCard", model) expects the action to prepare the model, while ViewComponent(...) fetches the data itself. This technique will be the basis of the htmx examples in the 11th post of this series.

Rendering a Razor Component in an MVC View

The third option is the Razor component, coming from the Blazor world. You can render a component with the .razor extension that takes its parameters through [Parameter] inside an MVC (Model-View-Controller) view. A small component that shows a book's rating as stars:

<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; }
}

Next to the Components/BookRating.razor file we added a Components/_Imports.razor for the namespaces the components use:

@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web

In the details view we call it with the <component> tag helper:

<component type="typeof(Bookstore.Web.Components.BookRating)" render-mode="Static" param-Stars="4" />

We added nothing to Program.cs; AddControllersWithViews() is enough for static rendering. The real output:

<span class="rating" title="4 out of 5"><span>★</span><span>★</span><span>★</span><span>★</span><span>☆</span></span>

render-mode="Static" renders the component once and leaves plain HTML; in this mode it is no different from a partial, only the authoring format is a Razor component. The real value is in other modes: when I wrote render-mode="ServerPrerendered", the page still returned 200 with the same HTML, but a comment block appeared around it:

<!--Blazor:{"type":"server","prerenderId":"5d17566d08a34dfc9997629a254014cc", ... ,"descriptor":"CfDJ8EINq/wm4TFC..."}--><span class="rating" title="4 out of 5">...</span><!--Blazor:{"prerenderId":"5d17566d08a34dfc9997629a254014cc"}-->

These markers are there for the Blazor script in the browser to find the component and make it interactive over a SignalR connection. Since the application has no Blazor Server services, hub endpoint or blazor.server.js, nothing happens: no error, no warning, just inert markers. The setup needed for interactive mode is the subject of the upcoming Blazor series; what you need to know on the MVC side is that Static mode needs no extra setup and the other modes do.

Which One When?

QuestionPartial viewView componentRazor component
Who prepares the data?The calling view/actionItself (takes services via DI)The caller, as parameters
Own logicNone, display onlyInside InvokeAsyncIn the @code block
Typical useList row, card, form fragmentMenu, cart summary, "recent posts", notification countUI (User Interface) shared during a move to Blazor, a piece that will become interactive
Returning from a controllerPartialView()ViewComponent()Not directly
Extra setupNone@addTagHelper *, Bookstore.Web for the tag helperNone for Static, Blazor for interactive

My own rule: repeated HTML whose data the caller already has is a partial; a fragment that appears on every page and has to find its own data is a view component; if the team is moving to Blazor, or the fragment will clearly become interactive one day, a Razor component.

In Production

A view component in the layout runs on every page request. Our cart summary adds one query to every page; that should be a measured and deliberate cost. For content that is read very often and changes rarely (such as a category menu), you can use IMemoryCache inside the component or the <cache> tag helper on the view side; the latter is in the next post.

An exception thrown inside a view component turns the whole page into a 500. If the cart service fails, the user cannot see the catalog either. For a non-critical fragment, catching the error inside InvokeAsync, logging it and returning an empty view protects the rest of the page. Do this deliberately; a component that swallows every error hides problems.

Finally, view components do not receive a CancellationToken parameter. Use HttpContext.RequestAborted; the code above does exactly that.

Common Pitfalls

Calling a service or a DbContext from a partial. We measured it above: 4 extra queries on a four-card page, all synchronous and outside the controller's field of view. A partial displays its model; it does not fetch data.

Using a view component like a partial. Turning a fragment whose data is already ready in the action into a view component and querying the same data again inside the component is double work.

Forgetting the @addTagHelper *, Bookstore.Web line. When I deleted the line and opened the page, the HTML had <vc:cart-summary b-v51vsz6my3 /> instead of the cart summary: the tag was written as is without any error (with the layout's CSS (Cascading Style Sheets) isolation attribute added on top), and the browser ignored the unknown tag; the cart summary silently disappeared. If the assembly name differs from the project name (for example, if <AssemblyName> was changed), you need to write the assembly's real name.

Not passing a model to a partial. The partial takes the caller's model; when the caller changes, the partial still compiles but blows up at runtime with the wrong type.

A Small Experiment

★☆☆ Add int Stock to BookListItemViewModel, update the projection in BookCatalog.ListAsync and show the stock on the card. Measure the queries per request in the Entity Framework Core (EF Core) command log: did it stay at 3?

★★☆ Write a CategoryMenuViewComponent that shows, for every value of the Genre enum, how many books of that genre are in the catalog, and add it to the layout. Then cache its result with IMemoryCache for 5 minutes and see in the log that the query count drops on the second request.

★★★ Write a POST action on CartController that adds an item to the cart (updating the cookie) and then returns ViewComponent("CartSummary"). Call it from the browser with fetch and replace the badge with the returned HTML. Should this POST be accepted without an antiforgery token? We answer that in the 6th post.

In our partials and layout we used tags like asp-controller, asp-action, <partial> and <vc:...>, but we never discussed how they work. In the next post we look inside tag helpers: what the built-in ones produce, how they differ from HTML Helpers, and how to write our own <status-badge> and <secure-link> tag helpers.