View Components — Reusable UI with DI and Logic

View Components are self-contained UI widgets with their own controller-like logic and view. Unlike partial views (which only render data passed from the parent), View Components have their own InvokeAsync method that can inject services, query the database, and compute data independently. This makes them ideal for UI elements that appear on many pages but need their own data: a navigation menu showing category counts, a recent posts sidebar, a notification bell with unread count, a shopping cart header. View Components are the correct answer to “how do I show database-driven UI in my layout without every action method having to fetch that data?”

Creating a View Component

// ── 1. The View Component class ────────────────────────────────────────────
public class RecentPostsViewComponent : ViewComponent
{
    private readonly IPostService _service;

    // Constructor injection — ViewComponent supports DI
    public RecentPostsViewComponent(IPostService service)
        => _service = service;

    // InvokeAsync — called when the component is rendered
    // Parameters can be passed from the calling view
    public async Task<IViewComponentResult> InvokeAsync(int count = 5)
    {
        var posts = await _service.GetRecentAsync(count);
        return View(posts);   // renders Views/Shared/Components/RecentPosts/Default.cshtml
    }
}

// ── 2. The View Component template ────────────────────────────────────────
// File: Views/Shared/Components/RecentPosts/Default.cshtml
@model IReadOnlyList<PostSummaryViewModel>

<div class="widget">
    <h4>Recent Posts</h4>
    <ul class="list-unstyled">
        @foreach (var post in Model)
        {
            <li>
                <a asp-controller="Posts" asp-action="Details"
                   asp-route-id="@post.Id">@post.Title</a>
                <small class="text-muted">@post.PublishedAt.ToString("MMM d")</small>
            </li>
        }
    </ul>
</div>

// ── 3. Invoke the View Component from a layout or view ────────────────────
// In _Layout.cshtml sidebar section:

@* Method 1: Component Tag Helper (preferred) *@
<vc:recent-posts count="5"></vc:recent-posts>
@* "recent-posts" = kebab-case of "RecentPosts" class name without "ViewComponent" suffix *@

@* Method 2: @await Component.InvokeAsync *@
@await Component.InvokeAsync("RecentPosts", new { count = 5 })
Note: View Component views are located at Views/Shared/Components/{ComponentName}/Default.cshtml. The Default.cshtml name is used when you call return View(model) without specifying a view name. You can return alternative views with return View("Alternative", model) — useful for rendering the same component differently based on context (compact vs expanded card layout). The Views/Shared/Components/ folder can also be placed inside a specific controller’s Views folder (Views/Posts/Components/) for controller-scoped components.
Tip: Register View Components in _ViewImports.cshtml for access to the <vc:...> Tag Helper syntax: @addTagHelper *, BlogApp.Web. Without this registration, <vc:recent-posts> is treated as an unknown HTML element and rendered as-is (not executed). The Tag Helper registration applies to custom Tag Helpers and View Component Tag Helpers in your own assembly. The built-in Tag Helpers (asp-for, etc.) are registered separately with @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers.
Warning: View Components are instantiated by the DI container per invocation, but they run in the view rendering phase, not the action phase. This means HttpContext is accessible through ViewComponentContext, but ModelState and action-level filters do not apply. If a View Component’s service call fails (database timeout, service unavailable), it throws during view rendering — the exception propagates to the action’s exception handler. Always handle exceptions in View Component InvokeAsync to avoid a database timeout in a sidebar widget bringing down the entire page.

Navigation Menu View Component Example

// ── NavigationMenuViewComponent.cs ────────────────────────────────────────
public class NavigationMenuViewComponent(ICategoryService categoryService) : ViewComponent
{
    public async Task<IViewComponentResult> InvokeAsync()
    {
        var categories = await categoryService.GetWithPostCountsAsync();
        return View(new NavigationMenuViewModel
        {
            Categories  = categories,
            CurrentPath = HttpContext.Request.Path,  // highlight active nav item
        });
    }
}

// ── Views/Shared/Components/NavigationMenu/Default.cshtml ─────────────────
@model NavigationMenuViewModel

<ul class="navbar-nav">
    <li class="nav-item @(Model.CurrentPath == "/" ? "active" : "")">
        <a class="nav-link" asp-controller="Home" asp-action="Index">Home</a>
    </li>
    @foreach (var cat in Model.Categories)
    {
        <li class="nav-item">
            <a class="nav-link" asp-controller="Posts"
               asp-action="ByCategory" asp-route-slug="@cat.Slug">
                @cat.Name <span class="badge">@cat.PostCount</span>
            </a>
        </li>
    }
</ul>

@* In _Layout.cshtml: *@
@* <vc:navigation-menu></vc:navigation-menu> *@

Common Mistakes

Mistake 1 — Wrong view file location (view not found exception)

❌ Wrong — view at Views/Shared/RecentPosts/Default.cshtml (missing Components folder).

✅ Correct — path must be Views/Shared/Components/RecentPosts/Default.cshtml.

Mistake 2 — Not handling exceptions in InvokeAsync (sidebar error crashes entire page)

❌ Wrong — database timeout in navigation menu View Component throws, entire page returns 500.

✅ Correct — wrap InvokeAsync in try/catch; return an empty or fallback view on error.

🧠 Test Yourself

The navigation menu appears on every page and needs to query the database for categories. Why is a View Component better than passing category data from every controller action via ViewBag?