ASP.NET Core MVC

View Components: повторювані незалежні блоки UI

View Components в ASP.NET Core MVC: базовий клас ViewComponent, метод InvokeAsync, структура папок Views/Shared/Components. Порівняння з Partial Views та Tag Helpers. DI у View Component. Демо: ShoppingCartViewComponent, NotificationBellViewComponent, BreadcrumbViewComponent.

View Components: повторювані незалежні блоки UI

Уявіть, що на кожній сторінці вашого інтернет-магазину міститься іконка кошика з кількістю товарів. Ця кількість бере дані з бази або сесії. У правому куті — дзвіночок сповіщень: скільки непрочитаних повідомлень. Вгорі — breadcrumbs (навігаційна стрічка), що формується динамічно залежно від поточної сторінки.

Як реалізувати ці компоненти «правильно»? Перший інстинкт — Partial View. Але Partial View не може виконувати власну логіку отримання даних: він просто рендерить модель, яку йому хтось передав. Передавайте дані через ViewModel кожного Action — і ваш MovieController.Index почне знати про кошик і сповіщення. Це порушення принципу єдиної відповідальності.

Саме для цього і створені View Components (Компоненти відображення) — незалежні, самодостатні блоки UI, що мають власну логіку, власні залежності та незалежні від Controller-а, на сторінці якого вони відображаються.


View Component vs Partial View vs Tag Helper

Перш ніж розбиратися як це будувати, зрозуміємо коли що обирати.

КритерійPartial ViewView ComponentTag Helper
Власна логіка отримання даних❌ Ні✅ Так❌ Ні
DI-залежності❌ Ні✅ Так✅ Так
Власна View-шаблон (.cshtml)✅ Так✅ Так❌ Ні (рядки/атрибути)
Кешування результату❌ Ручне✅ Вбудована підтримка
Складність реалізаціїМінімальнаСередняВисока
Типовий сценарійКартка статті, рядок таблиціКошик, меню, breadcrumbs<email>, форматування

Правило вибору просте:

  • Partial View — коли дані вже є в поточній моделі та їх треба просто відобразити.
  • View Component — коли потрібна незалежна логіка отримання даних (з бази, сесії, API).
  • Tag Helper — коли генерується HTML-розмітка без власного шаблону (атрибути, форматування).

Анатомія View Component

View Component складається з двох частин:

1. C# клас — успадковує від ViewComponent, містить метод InvokeAsync (або Invoke для синхронного варіанту). Тут живе вся логіка.

2. View-шаблон — звичайний .cshtml файл за конвенційним шляхом Views/Shared/Components/{ComponentName}/Default.cshtml.

Views/
└── Shared/
    └── Components/                    ← папка для всіх View Components
        ├── ShoppingCart/
        │   └── Default.cshtml         ← шаблон для ShoppingCartViewComponent
        ├── NotificationBell/
        │   └── Default.cshtml
        └── Breadcrumb/
            └── Default.cshtml

Назва папки — ім'я класу без суфікса ViewComponent. Ім'я файлу за замовчуванням — Default.cshtml. Ім'я можна змінити, повернувши View("CustomName", model) з InvokeAsync.


Базовий клас ViewComponent

ViewComponents/ShoppingCartViewComponent.cs
using Microsoft.AspNetCore.Mvc;

namespace ShopApp.ViewComponents;

// Конвенція: суфікс ViewComponent в імені класу
public class ShoppingCartViewComponent : ViewComponent
{
    // InvokeAsync — точка входу, може приймати довільні параметри
    public async Task<IViewComponentResult> InvokeAsync()
    {
        // Логіка тут — повністю незалежна від Controller
        // ...
        return View(someModel); // рендерить Default.cshtml з переданою моделлю
    }
}

Метод InvokeAsync повертає IViewComponentResult. На практиці майже завжди View(model) — що рендерить Default.cshtml. Але є ще:

  • View("ViewName", model) — рендеринг іменованого шаблону
  • Content("рядок") — повернення простого рядка без шаблону

Параметри InvokeAsync є довільними — ви самі визначаєте їх сигнатуру. При виклику з View вони передаються як іменовані аргументи.


Виклик View Component з View

Є два еквівалентних синтаксиси виклику:

Синтаксис 1: Component.InvokeAsync (традиційний)

@* Виклик без параметрів *@
@await Component.InvokeAsync("ShoppingCart")

@* Виклик з параметрами (анонімний об'єкт) *@
@await Component.InvokeAsync("NotificationBell", new { userId = currentUserId })

@* Виклик з параметром *@
@await Component.InvokeAsync("Breadcrumb", new { items = breadcrumbItems })

Синтаксис 2: Tag Helper (рекомендований, сучасніший)

@* Потрібно у _ViewImports.cshtml: @addTagHelper *, YourProjectName *@

@* Без параметрів *@
<vc:shopping-cart></vc:shopping-cart>

@* З параметрами — kebab-case ім'я компоненту та параметрів *@
<vc:notification-bell user-id="@currentUserId"></vc:notification-bell>

@* З кількома параметрами *@
<vc:breadcrumb items="@breadcrumbItems"></vc:breadcrumb>

Tag Helper-синтаксис є кращим: він виглядає як HTML і підтримує IntelliSense у Visual Studio / Rider.

Назва тегу формується з назви класу (без суфікса ViewComponent) у kebab-case: ShoppingCartViewComponent<vc:shopping-cart>. Параметри методу InvokeAsync також переводяться у kebab-case: userIduser-id.

Демо-проєкт: три View Components для інтернет-магазину

Розгортаємо проєкт з нуля — усі файли наведені повністю, щоб можна було скопіювати та запустити.

Створення проєкту

dotnet new mvc -n ShopApp
cd ShopApp

Налаштування _ViewImports.cshtml

Для роботи Tag Helper синтаксису (<vc:...>) додайте директиву @addTagHelper у файл Views/_ViewImports.cshtml. Замініть весь вміст:

Views/_ViewImports.cshtml
@using ShopApp
@using ShopApp.Models
@using ShopApp.ViewComponents
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, ShopApp
Рядок @addTagHelper *, ShopApp — саме він вмикає <vc:...> синтаксис. Ім'я після *, — це назва вашої збірки (збігається з <AssemblyName> у .csproj, або ж назва проєкту).

Компонент 1: ShoppingCartViewComponent — лічильник кошика

Відображає іконку кошика з кількістю товарів у навбарі. Кількість зчитується з сесії.

Services/ICartService.cs
namespace ShopApp.Services;

public interface ICartService
{
    Task<int> GetItemCountAsync(string sessionId);
    Task<decimal> GetTotalPriceAsync(string sessionId);
    Task AddItemAsync(string sessionId, string productId, int quantity);
}
Services/SessionCartService.cs
namespace ShopApp.Services;

// Проста in-memory реалізація для демо.
// У реальному проєкті тут буде робота з БД або зовнішнім кошиком.
public class SessionCartService : ICartService
{
    // Імітуємо сховище — стан заради демо зберігаємо статично
    private static readonly Dictionary<string, List<(string ProductId, int Qty, decimal Price)>> _store = [];

    public Task<int> GetItemCountAsync(string sessionId)
    {
        if (!_store.TryGetValue(sessionId, out var items))
            return Task.FromResult(0);

        return Task.FromResult(items.Sum(i => i.Qty));
    }

    public Task<decimal> GetTotalPriceAsync(string sessionId)
    {
        if (!_store.TryGetValue(sessionId, out var items))
            return Task.FromResult(0m);

        return Task.FromResult(items.Sum(i => i.Price * i.Qty));
    }

    public Task AddItemAsync(string sessionId, string productId, int quantity)
    {
        if (!_store.TryGetValue(sessionId, out var items))
        {
            items = [];
            _store[sessionId] = items;
        }

        // Для демо — ціна фіксована 100 грн за одиницю
        items.Add((productId, quantity, 100m));
        return Task.CompletedTask;
    }
}
ViewComponents/ShoppingCartViewComponent.cs
using Microsoft.AspNetCore.Mvc;
using ShopApp.Services;

namespace ShopApp.ViewComponents;

public class ShoppingCartViewComponent : ViewComponent
{
    private readonly ICartService _cart;

    // DI — ін'єктуємо залежності через конструктор, як у звичайному сервісі
    public ShoppingCartViewComponent(ICartService cart)
    {
        _cart = cart;
    }

    // Параметрів немає — все що потрібно, зчитуємо самостійно
    public async Task<IViewComponentResult> InvokeAsync()
    {
        // HttpContext доступний через властивість HttpContext (від ViewComponent)
        var sessionId = HttpContext.Session.GetString("CartId") ?? "default";

        var itemCount = await _cart.GetItemCountAsync(sessionId);
        var totalPrice = await _cart.GetTotalPriceAsync(sessionId);

        var model = new CartSummary(itemCount, totalPrice);
        return View(model); // → Views/Shared/Components/ShoppingCart/Default.cshtml
    }
}

// Мінімальна модель для View
public record CartSummary(int ItemCount, decimal TotalPrice);
Views/Shared/Components/ShoppingCart/Default.cshtml
@model ShopApp.ViewComponents.CartSummary

<a href="/cart" class="nav-link position-relative">
    <i class="bi bi-cart3 fs-5"></i>

    @if (Model.ItemCount > 0)
    {
        @* Бейдж з кількістю товарів *@
        <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
            @(Model.ItemCount > 99 ? "99+" : Model.ItemCount.ToString())
            <span class="visually-hidden">товарів у кошику</span>
        </span>
    }
</a>

Компонент 2: NotificationBellViewComponent — непрочитані сповіщення

Відображає дзвіночок з кількістю непрочитаних сповіщень для поточного користувача. Приймає userId як параметр.

Services/INotificationService.cs
namespace ShopApp.Services;

public interface INotificationService
{
    Task<int> GetUnreadCountAsync(string userId);
    Task<IReadOnlyList<NotificationItem>> GetRecentAsync(string userId, int limit);
}

// Тут, а не у ViewComponent — щоб сервіс міг їх повертати безпосередньо
public record NotificationItem(string Message, DateTime CreatedAt, bool IsRead);
Services/StubNotificationService.cs
namespace ShopApp.Services;

// Заглушка для демо — повертає статичні дані
public class StubNotificationService : INotificationService
{
    private static readonly List<NotificationItem> _notifications =
    [
        new("Ваше замовлення #1042 відправлено", DateTime.Now.AddMinutes(-15), false),
        new("Акція: знижка 20% на електроніку до кінця дня!", DateTime.Now.AddHours(-2), false),
        new("Новий коментар до вашого відгуку", DateTime.Now.AddHours(-5), true),
        new("Замовлення #1041 доставлено", DateTime.Now.AddDays(-1), true),
        new("Вітаємо з реєстрацією!", DateTime.Now.AddDays(-7), true),
    ];

    public Task<int> GetUnreadCountAsync(string userId)
    {
        var count = _notifications.Count(n => !n.IsRead);
        return Task.FromResult(count);
    }

    public Task<IReadOnlyList<NotificationItem>> GetRecentAsync(string userId, int limit)
    {
        IReadOnlyList<NotificationItem> result = _notifications.Take(limit).ToList();
        return Task.FromResult(result);
    }
}
ViewComponents/NotificationBellViewComponent.cs
using Microsoft.AspNetCore.Mvc;
using ShopApp.Services;

namespace ShopApp.ViewComponents;

public class NotificationBellViewComponent : ViewComponent
{
    private readonly INotificationService _notifications;

    public NotificationBellViewComponent(INotificationService notifications)
    {
        _notifications = notifications;
    }

    // Параметр userId — передається при виклику з View
    public async Task<IViewComponentResult> InvokeAsync(string userId)
    {
        // Гість — сповіщень немає
        if (string.IsNullOrEmpty(userId) || userId == "guest")
        {
            return View(new NotificationSummary(0, []));
        }

        var unreadCount = await _notifications.GetUnreadCountAsync(userId);
        var recentNotifications = await _notifications.GetRecentAsync(userId, limit: 5);

        var model = new NotificationSummary(unreadCount, recentNotifications);
        return View(model);
    }
}

public record NotificationSummary(int UnreadCount, IReadOnlyList<NotificationItem> Recent);
Views/Shared/Components/NotificationBell/Default.cshtml
@using ShopApp.Services
@model ShopApp.ViewComponents.NotificationSummary

<div class="dropdown">
    <button class="btn btn-link nav-link position-relative dropdown-toggle"
            data-bs-toggle="dropdown">
        <i class="bi bi-bell fs-5"></i>
        @if (Model.UnreadCount > 0)
        {
            <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-primary">
                @Model.UnreadCount
            </span>
        }
    </button>

    <div class="dropdown-menu dropdown-menu-end shadow" style="min-width: 300px;">
        <h6 class="dropdown-header">Сповіщення</h6>
        @if (!Model.Recent.Any())
        {
            <div class="dropdown-item-text text-muted small">Немає нових сповіщень</div>
        }
        else
        {
            @foreach (var n in Model.Recent)
            {
                <div class="dropdown-item @(n.IsRead ? "" : "fw-bold")">
                    <div class="small">@n.Message</div>
                    <div class="text-muted" style="font-size: 0.75rem;">
                        @n.CreatedAt.ToString("dd MMM, HH:mm")
                    </div>
                </div>
            }
            <div class="dropdown-divider"></div>
            <a href="/notifications" class="dropdown-item text-center small">
                Всі сповіщення →
            </a>
        }
    </div>
</div>

Компонент 3: BreadcrumbViewComponent — динамічна навігаційна стрічка

Breadcrumbs — навігація «де я знаходжусь». Приймає список елементів і рендерить їх.

ViewComponents/BreadcrumbViewComponent.cs
using Microsoft.AspNetCore.Mvc;

namespace ShopApp.ViewComponents;

public class BreadcrumbViewComponent : ViewComponent
{
    // Цей компонент не потребує DI-сервісів — лише рендер переданих даних
    // Але він все одно View Component (а не Partial View), бо може мати власну логіку
    public IViewComponentResult Invoke(IReadOnlyList<BreadcrumbItem> items)
    {
        // Додаємо «Головна» на початок, якщо не передано
        var allItems = items.Any() && items[0].Url == "/"
            ? items
            : new[] { new BreadcrumbItem("Головна", "/") }.Concat(items).ToList();

        return View(allItems);
    }
}

// Синхронний Invoke — можна використовувати якщо немає async операцій
public record BreadcrumbItem(string Label, string? Url = null);
Views/Shared/Components/Breadcrumb/Default.cshtml
@model IReadOnlyList<ShopApp.ViewComponents.BreadcrumbItem>

@if (Model.Count > 1) // Показуємо лише якщо є більше одного елементу
{
    <nav aria-label="breadcrumb">
        <ol class="breadcrumb">
            @for (int i = 0; i < Model.Count; i++)
            {
                var item = Model[i];
                bool isLast = i == Model.Count - 1;

                if (isLast)
                {
                    @* Поточна сторінка — без посилання *@
                    <li class="breadcrumb-item active" aria-current="page">@item.Label</li>
                }
                else
                {
                    <li class="breadcrumb-item">
                        <a href="@item.Url">@item.Label</a>
                    </li>
                }
            }
        </ol>
    </nav>
}

Підключення Layout із усіма трьома компонентами

Views/Shared/_Layout.cshtml
<!DOCTYPE html>
<html lang="uk">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] — ShopApp</title>
    <link rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" />
    <link rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="/">ShopApp</a>
            <div class="navbar-nav ms-auto align-items-center gap-2">
                @* View Component викликається без жодних параметрів з Controller'ів *@
                <vc:shopping-cart></vc:shopping-cart>
                <vc:notification-bell user-id="demo-user"></vc:notification-bell>
            </div>
        </div>
    </nav>

    <div class="container mt-3">
        @* Breadcrumb передається з конкретного View — тут лише місце для нього *@
        @RenderSection("Breadcrumb", required: false)
        <main role="main">
            @RenderBody()
        </main>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Демонстраційна сторінка продукту з Breadcrumb

Views/Home/Index.cshtml
@using ShopApp.ViewComponents
@{
    ViewData["Title"] = "Каталог товарів";

    var breadcrumbs = new List<BreadcrumbItem>
    {
        new("Каталог", "/catalog"),
        new("Електроніка", "/catalog/electronics"),
        new("Ноутбук ProBook 450") // остання — без URL (поточна сторінка)
    };
}

@section Breadcrumb {
    <vc:breadcrumb items="@breadcrumbs"></vc:breadcrumb>
}

<h1>Ноутбук ProBook 450</h1>
<p class="text-muted">Демонстрація трьох View Components у дії.</p>

<div class="alert alert-info">
    Подивіться на навбар: там працюють <strong>ShoppingCart</strong> та
    <strong>NotificationBell</strong> View Components.<br/>
    Вгорі — <strong>Breadcrumb</strong> View Component.
</div>

Реєстрація сервісів у Program.cs

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

// Додаємо Session (потрібно для ShoppingCartViewComponent)
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromHours(1);
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});

// Сервіси, що використовуються у View Components
builder.Services.AddScoped<ICartService, SessionCartService>();
builder.Services.AddScoped<INotificationService, StubNotificationService>();

var app = builder.Build();

app.UseSession(); // ← обов'язково перед UseRouting
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

app.MapDefaultControllerRoute();
app.Run();
View Components реєструються в DI автоматично — вам не потрібно явно додавати їх як сервіси. ASP.NET Core сканує збірку і знаходить всі класи що успадковують ViewComponent або мають суфікс ViewComponent.

Запуск проєкту

dotnet run

Відкрийте https://localhost:5001 — у навбарі побачите іконку кошика та дзвіночок зі сповіщеннями, вгорі сторінки — навігаційну стрічку breadcrumbs.


Кешування View Components

View Components добре піддаються кешуванню через Cache Tag Helper прямо у місці виклику:

@* Кешувати результат компонента на 5 хвилин *@
<cache expires-after="@TimeSpan.FromMinutes(5)">
    <vc:shopping-cart></vc:shopping-cart>
</cache>

@* Кешувати окремо для кожного користувача *@
<cache vary-by-user="true" expires-after="@TimeSpan.FromMinutes(1)">
    <vc:notification-bell user-id="@(User.Identity?.Name)"></vc:notification-bell>
</cache>

@* Breadcrumbs зазвичай не кешуємо — вони унікальні для кожної сторінки *@
<vc:breadcrumb items="@breadcrumbs"></vc:breadcrumb>

<cache> — це вбудований Tag Helper ASP.NET Core, що використовує IMemoryCache. Він є «обгорткою» навколо будь-якого HTML-контенту, включно з виводом View Components.


View Component у Area

View Components можна організувати й у Area. Тоді шлях до шаблону:

Areas/{AreaName}/Views/Shared/Components/{ComponentName}/Default.cshtml

При цьому Component клас залишається у глобальному або Area-namespace — ASP.NET Core шукає шаблон спочатку в Area, потім у глобальному Views/Shared/Components/.

Areas/Admin/Views/Shared/Components/AdminStats/Default.cshtml
@* Це шаблон, специфічний для Admin Area *@
@model AdminStatsModel
<div class="admin-stats">...</div>

Практичні завдання

Рівень 1 — Базовий

Завдання 1.1. Створіть LatestPostsViewComponent що отримує список із 3 останніх опублікованих статей через IArticleService і відображає їх як простий <ul> з посиланнями. Викличте його у _Layout.cshtml у сайдбарі через Tag Helper синтаксис <vc:latest-posts>.

Завдання 1.2. Модифікуйте LatestPostsViewComponent — додайте параметр int count = 3 у InvokeAsync. Тепер у деяких View виводьте 5 статей: <vc:latest-posts count="5">. Переконайтеся ДEFAULT значення 3 все ще працює без параметра.

Рівень 2 — Логіка

Завдання 2.1. Реалізуйте CategoryMenuViewComponent для інтернет-магазину. Він має:

  • Отримати список категорій з ICategoryService (Id, Name, Slug, ProductCount)
  • Відсортувати за ProductCount desc
  • View — вертикальне меню у Bootstrap list-group, де у кожного елемента є назва та лічильник <span class="badge">@count</span>
  • Активна категорія (поточна) підсвічується, якщо slug збігається з RouteData.Values["slug"]

Завдання 2.2. Огорніть <vc:category-menu> у <cache vary-by-route="slug" expires-after="@TimeSpan.FromMinutes(10)">. Поясніть в коментарях: чому vary-by-route="slug" потрібен, і що станеться якщо його прибрати.

Рівень 3 — Архітектура

Завдання 3.1. Реалізуйте SearchSuggestionsViewComponent що повертає <ul> з підказками пошуку:

  • Приймає string query та string context (наприклад, "products" або "articles")
  • Якщо query.Length < 2 — повертає Content("") (порожньо, без рендерингу шаблону)
  • В іншому випадку — звертається до відповідного сервісу (IProductSearchService або IArticleSearchService) через IServiceProvider та параметр context
  • View — <ul class="suggestions-list"> з не більш ніж 5 результатами
  • Викличте його через Component.InvokeAsync у AJAX-запиті з HTMX (hx-get="/search-suggestions?query=...") — Controller повертає PartialView що містить лише @await Component.InvokeAsync(...)

Резюме

  • View Component — самодостатній блок UI з власною логікою та DI-залежностями. Відрізняється від Partial View тим, що сам отримує потрібні дані, не покладаючись на Controller
  • Два елементи: C# клас (успадковує ViewComponent) + шаблон Views/Shared/Components/{Name}/Default.cshtml
  • InvokeAsync(params) — вхідна точка компонента. Параметри довільні, передаються при виклику
  • Два синтаксиси виклику: @await Component.InvokeAsync("Name", args) та Tag Helper <vc:name param="value"> (рекомендований)
  • _ViewImports.cshtml: рядок @addTagHelper *, ShopApp — обов'язковий для Tag Helper синтаксису <vc:...>
  • DI: залежності ін'єктуються через конструктор, як у звичайних сервісах. Самі компоненти реєструються автоматично
  • Кешування: через <cache> Tag Helper прямо на місці виклику компонента
  • Типові сценарії: кошик у навбарі, сповіщення, breadcrumbs, сайдбар з категоріями, статистичні віджети

У наступній статті — Display та Editor Templates: як оголосити власний спосіб відображення та редагування типу (наприклад, Money або DateRange) і автоматично застосовувати його через Html.DisplayFor() та Html.EditorFor().