ASP.NET Core MVC

Views, ViewData, ViewBag, TempData і ViewModel

Передача даних з Controller у View в ASP.NET Core MVC: три підходи (ViewData, ViewBag, strongly-typed ViewModel), структура папки Views, TempData для cross-request повідомлень, Partial Views у MVC. Демо: MovieController з Flash Messages та комплексним ViewModel.

Views, ViewData, ViewBag, TempData і ViewModel

У Razor Pages дані передавалися у View через властивості PageModel — прості публічні поля класу. У MVC все інакше: Controller і View — окремі класи, і між ними потрібен явний «канал» передачі даних. ASP.NET Core надає три механізми — і один з них значно кращий за інші.


Файлова структура Views

Перш ніж розбиратися з передачею даних, розберемо де живуть Views.

Views/
├── Shared/                          ← спільні Views для всіх Controllers
│   ├── _Layout.cshtml               ← базовий шаблон (те саме що у Razor Pages)
│   ├── Error.cshtml                 ← сторінка помилки
│   └── _ValidationScriptsPartial.cshtml
├── _ViewStart.cshtml                ← вказує _Layout для всіх Views
├── _ViewImports.cshtml              ← глобальні using та @addTagHelper
├── Home/                            ← папка = ім'я Controller (без "Controller")
│   ├── Index.cshtml                 ← Action name
│   └── About.cshtml
├── Movie/                           ← MovieController
│   ├── Index.cshtml
│   ├── Details.cshtml
│   └── Create.cshtml
└── Product/                         ← ProductController
    └── Index.cshtml

Як Controller знаходить View?

public class MovieController : Controller
{
    public IActionResult Index()
    {
        // View() без аргументів → шукає Views/Movie/Index.cshtml
        return View();
    }

    public IActionResult Details(int id)
    {
        // View(model) → Views/Movie/Details.cshtml + передає model
        return View(someModel);
    }

    public IActionResult SpecialPage()
    {
        // View("CustomName") → Views/Movie/CustomName.cshtml
        return View("CustomName");
    }

    public IActionResult SharedPage()
    {
        // View з абсолютним шляхом → Views/Shared/CommonView.cshtml
        return View("~/Views/Shared/CommonView.cshtml");
    }
}

Конвенція: {ControllerName}/{ActionName}.cshtml. Якщо View не знайдено — ASP.NET шукає у Views/Shared/.


Три способи передачі даних: порівняння

Спосіб 1: ViewData — словник типу object

ViewData — це ViewDataDictionary, словник string → object. Доступний і в Controller, і у View:

Controller
public IActionResult Index()
{
    ViewData["Title"] = "Список фільмів";             // string
    ViewData["TotalCount"] = 42;                       // int
    ViewData["IsAdmin"] = User.IsInRole("Admin");      // bool
    ViewData["Movies"] = await _service.GetAllAsync(); // List<Movie>

    return View();
}
View
@* Потрібне явне приведення типів — compile-time перевірки немає *@
<h1>@ViewData["Title"]</h1>
<p>Всього: @ViewData["TotalCount"]</p>

@* Небезпечно: якщо ключ відсутній → null, якщо тип не той → RuntimeException *@
@{
    var movies = ViewData["Movies"] as List<Movie>;
    var count = (int)ViewData["TotalCount"]!; // explicit cast потрібен
}
@foreach (var m in movies ?? [])
{
    <div>@m.Title</div>
}

Недоліки: немає compile-time перевірки, потрібні касти, магічні рядки-ключі.


Спосіб 2: ViewBag — dynamic wrapper над ViewData

ViewBag — той самий ViewData, але через dynamic синтаксис:

Controller
public IActionResult Index()
{
    ViewBag.Title = "Список фільмів";     // те саме що ViewData["Title"]
    ViewBag.TotalCount = 42;
    ViewBag.IsAdmin = User.IsInRole("Admin");
    // Насправді зберігається у ViewData["Title"], ViewData["TotalCount"]...

    return View();
}
View
<h1>@ViewBag.Title</h1>          @* dynamic — без каст *@
<p>Всього: @ViewBag.TotalCount</p>

@* Але все одно немає IntelliSense і compile-time перевірки *@
@if (ViewBag.IsAdmin)
{
    <a href="/admin">Адмін-панель</a>
}

Недоліки: dynamic — жодної compile-time перевірки, помилки лише в runtime, немає IntelliSense.

ViewData і ViewBag — це два інтерфейси до одного сховища. ViewBag.Title = "x" та ViewData["Title"] = "x" — рівнозначні операції. Тому у View можна встановити через ViewBag, а прочитати через ViewData і навпаки.

Спосіб 3: Strongly-Typed ViewModel ✅ (рекомендований)

ViewModel — це C# клас або record, спеціально створений для конкретного View. Типізована модель, яку передають через View(model):

Models/ViewModels/MovieIndexViewModel.cs
namespace MovieApp.Models.ViewModels;

public record MovieIndexViewModel(
    List<Movie> Movies,
    int TotalCount,
    string? FilterGenre,
    bool IsAdmin,
    int CurrentPage,
    int TotalPages
);
Controller
public async Task<IActionResult> Index(string? genre, int page = 1)
{
    var movies = await _service.GetAllAsync(genre, page);
    var totalCount = await _service.CountAsync(genre);

    // Створюємо ViewModel з усіма потрібними даними
    var viewModel = new MovieIndexViewModel(
        Movies: movies,
        TotalCount: totalCount,
        FilterGenre: genre,
        IsAdmin: User.IsInRole("Admin"),
        CurrentPage: page,
        TotalPages: (int)Math.Ceiling(totalCount / 10.0)
    );

    return View(viewModel); // ← передаємо ViewModel
}
View
@* Оголошуємо тип моделі — тепер є IntelliSense і compile-time перевірка *@
@model MovieApp.Models.ViewModels.MovieIndexViewModel

<h1>Фільми (@Model.TotalCount)</h1>

@if (Model.IsAdmin)
{
    <a asp-action="Create" class="btn btn-primary">Додати фільм</a>
}

@foreach (var movie in Model.Movies)
{
    <div>@movie.Title — @movie.Genre</div>
}

@* Пагінація *@
<nav>
    @for (int i = 1; i <= Model.TotalPages; i++)
    {
        <a asp-action="Index"
           asp-route-page="@i"
           asp-route-genre="@Model.FilterGenre"
           class="@(i == Model.CurrentPage ? "btn btn-primary" : "btn btn-outline-secondary")">
            @i
        </a>
    }
</nav>

Переваги ViewModel:

  • ✅ IntelliSense у View
  • ✅ Compile-time перевірка — помилки при компіляції, не в runtime
  • asp-for Tag Helper працює з властивостями ViewModel
  • ✅ Легко тестувати (просто данні, ніяких HttpContext)
  • ✅ Один View = один ViewModel = чіткий контракт

Коли використовувати що?

СценарійРекомендація
Основні дані сторінкиViewModel — завжди
Заголовок сторінки <title>ViewData["Title"] — загальноприйнята конвенція
Breadcrumbs, поточна секція менюViewData або ViewBag — дрібні метадані
Дані з Parent для LayoutViewData — так як Layout не має доступу до ViewModel
Flash Messages після redirectTempData

TempData: повідомлення між запитами

TempData — це сховище що живе між двома запитами. Ідеально для Flash Messages (повідомлень після redirect):

Controller
// POST /movie/create — обробляємо форму
[HttpPost]
public async Task<IActionResult> Create(CreateMovieDto dto)
{
    if (!ModelState.IsValid) return View(dto);

    var movie = await _service.CreateAsync(dto);

    // Зберігаємо повідомлення — воно переживе redirect
    TempData["Success"] = $"Фільм «{movie.Title}» успішно додано!";
    TempData["NewMovieId"] = movie.Id; // можна зберігати прості типи

    // Redirect — перший запит завершено
    return RedirectToAction(nameof(Index));
}

// GET /movie — після redirect
public async Task<IActionResult> Index()
{
    var movies = await _service.GetAllAsync();
    // TempData["Success"] буде доступний у View
    // Після читання він автоматично видаляється
    return View(movies);
}
View: _Layout.cshtml — глобальні Flash Messages
@* У _Layout.cshtml — щоб Flash Messages з'являлися на будь-якій сторінці *@
@if (TempData["Success"] is string successMsg)
{
    <div class="alert alert-success alert-dismissible fade show" role="alert">
        <i class="bi bi-check-circle me-2"></i>@successMsg
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    </div>
}
@if (TempData["Error"] is string errorMsg)
{
    <div class="alert alert-danger alert-dismissible fade show" role="alert">
        <i class="bi bi-exclamation-triangle me-2"></i>@errorMsg
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    </div>
}

TempData.Keep() і TempData.Peek()

// TempData читається один раз і видаляється
// Якщо потрібно зберегти для наступного запиту:
TempData.Keep("Success");   // зберегти після читання

// Прочитати без видалення:
var msg = TempData.Peek("Success") as string;

TempData vs ViewData vs Session

ViewData/ViewBagTempDataSession
ЖивеВ рамках одного запитуМіж двома запитами (Request→Redirect→Request)До явного очищення або timeout
ЗберіганняIn-memory (HTTP context)Cookie або SessionSession store
Типи значеньБудь-якіПрості типи (string, int, bool)Прості типи
ВикористанняПоточна ViewFlash Messages після redirectКошик, профіль користувача

Partial Views у MVC

Partial Views — ті самі .cshtml файли що і у Razor Pages, але розташовані у Views/Shared/ або Views/{Controller}/:

Views/Shared/_MovieCard.cshtml
@* Partial View приймає власну модель *@
@model MovieApp.Models.Movie

<div class="card mb-3">
    <div class="card-body">
        <h5 class="card-title">@Model.Title</h5>
        <p class="card-text text-muted">@Model.Genre · @Model.Year</p>
        <a asp-controller="Movie" asp-action="Details"
           asp-route-id="@Model.Id" class="btn btn-sm btn-outline-primary">
            Детальніше
        </a>
    </div>
</div>
Використання у View
@model List<Movie>

@* Варіант 1: <partial> tag helper (рекомендований) *@
@foreach (var movie in Model)
{
    <partial name="_MovieCard" model="movie" />
}

@* Варіант 2: Html.PartialAsync (старіший стиль) *@
@await Html.PartialAsync("_MovieCard", movie)

@* Варіант 3: Html.Partial (синхронний, не рекомендований) *@
@Html.Partial("_MovieCard", movie)

Демо-проєкт: MovieController

Будуємо покроково Controller що демонструє всі три механізми передачі даних та TempData.

Крок 1: Моделі

Models/Movie.cs
namespace MovieApp.Models;

public record Movie(
    int Id,
    string Title,
    string Genre,
    int Year,
    string Director,
    decimal Rating,
    bool IsAvailable
);
Models/ViewModels/MovieIndexViewModel.cs
namespace MovieApp.Models.ViewModels;

// Strongly-typed ViewModel для сторінки списку
public record MovieIndexViewModel(
    IReadOnlyList<Movie> Movies,
    IReadOnlyList<string> Genres,  // для dropdown фільтра
    string? ActiveGenre,
    int TotalCount,
    bool CanAddMovies              // умова показу кнопки "Додати"
);
Models/CreateMovieDto.cs
using System.ComponentModel.DataAnnotations;

namespace MovieApp.Models;

public class CreateMovieDto
{
    [Required(ErrorMessage = "Вкажіть назву")]
    [StringLength(200, MinimumLength = 2)]
    public string Title { get; set; } = "";

    [Required] public string Genre { get; set; } = "";
    [Required] public string Director { get; set; } = "";

    [Range(1888, 2030, ErrorMessage = "Рік від 1888 до 2030")]
    public int Year { get; set; } = DateTime.Now.Year;

    [Range(0, 10, ErrorMessage = "Рейтинг від 0 до 10")]
    public decimal Rating { get; set; }
}

Крок 2: Controller з трьома механізмами

Controllers/MovieController.cs
using Microsoft.AspNetCore.Mvc;
using MovieApp.Models;
using MovieApp.Models.ViewModels;
using MovieApp.Services;

namespace MovieApp.Controllers;

public class MovieController : Controller
{
    private readonly IMovieService _service;

    public MovieController(IMovieService service)
    {
        _service = service;
    }

    // ─── Спосіб 3: STRONGLY-TYPED VIEWMODEL (рекомендований) ─────
    // GET /movie
    public async Task<IActionResult> Index(string? genre)
    {
        var movies = genre is null
            ? await _service.GetAllAsync()
            : await _service.GetByGenreAsync(genre);

        var allGenres = await _service.GetAllGenresAsync();

        // ViewData["Title"] — загальноприйнята конвенція для <title>
        ViewData["Title"] = genre is null ? "Всі фільми" : $"Жанр: {genre}";

        // ViewModel містить всі дані що потрібні View
        var viewModel = new MovieIndexViewModel(
            Movies: movies,
            Genres: allGenres,
            ActiveGenre: genre,
            TotalCount: movies.Count,
            CanAddMovies: true // пізніше: User.IsInRole("Admin")
        );

        return View(viewModel);
    }

    // ─── ДЕТАЛІ: простий ViewModel = сам об'єкт ──────────────────
    // GET /movie/details/5
    public async Task<IActionResult> Details(int id)
    {
        var movie = await _service.GetByIdAsync(id);
        if (movie is null) return NotFound();

        // ViewData для breadcrumbs — дрібні метадані
        ViewData["Title"] = movie.Title;
        ViewData["BreadcrumbTitle"] = movie.Title;

        // Передаємо Movie напряму — він і є ViewModel
        return View(movie);
    }

    // ─── CREATE: форма та збереження ─────────────────────────────
    [HttpGet]
    public IActionResult Create()
    {
        // Ініціалізуємо DTO для форми
        return View(new CreateMovieDto());
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateMovieDto dto)
    {
        if (!ModelState.IsValid)
            return View(dto);

        var movie = await _service.CreateAsync(dto);

        // TempData — Flash Message що переживе redirect
        TempData["Success"] = $"Фільм «{movie.Title}» ({movie.Year}) додано до каталогу!";

        return RedirectToAction(nameof(Index));
    }

    // ─── DELETE з Flash Message ───────────────────────────────────
    [HttpPost]
    public async Task<IActionResult> Delete(int id)
    {
        var movie = await _service.GetByIdAsync(id);
        if (movie is null) return NotFound();

        await _service.DeleteAsync(id);

        TempData["Success"] = $"Фільм «{movie.Title}» видалено.";
        return RedirectToAction(nameof(Index));
    }

    // ─── ДЕМОНСТРАЦІЯ: ViewBag і ViewData порівняно ───────────────
    // GET /movie/comparison — показує всі три підходи на одній сторінці
    public IActionResult Comparison()
    {
        // ViewData (словник)
        ViewData["Title"] = "Порівняння підходів";
        ViewData["MovieCount"] = 42;
        ViewData["FeaturedMovie"] = new Movie(1, "Тіні забутих предків", "Драма",
            1965, "Сергій Параджанов", 9.2m, true);

        // ViewBag (dynamic — те саме сховище)
        ViewBag.PageSubtitle = "ViewData vs ViewBag vs ViewModel";
        ViewBag.IsHighlighted = true;

        // Strongly-typed ViewModel — передаємо через View(model)
        var vm = new MovieIndexViewModel(
            Movies: [],
            Genres: ["Драма", "Комедія", "Трилер"],
            ActiveGenre: null,
            TotalCount: 0,
            CanAddMovies: false
        );

        return View("Comparison", vm);
    }
}

Крок 3: Views

Views/Movie/Index.cshtml
@* Strongly-typed ViewModel *@
@model MovieApp.Models.ViewModels.MovieIndexViewModel
@{ ViewData["Title"] ??= "Фільми"; }

<div class="d-flex justify-content-between align-items-center mb-3">
    <h1>@ViewData["Title"] <span class="text-muted fs-5">(@Model.TotalCount)</span></h1>
    @if (Model.CanAddMovies)
    {
        <a asp-action="Create" class="btn btn-primary">+ Додати фільм</a>
    }
</div>

@* Flash Message з TempData *@
@if (TempData["Success"] is string msg)
{
    <div class="alert alert-success alert-dismissible fade show">
        @msg
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    </div>
}

@* Фільтр по жанру (ViewModel надає список жанрів) *@
<div class="mb-3">
    <a asp-action="Index" class="btn btn-sm @(Model.ActiveGenre is null ? "btn-dark" : "btn-outline-dark")">
        Всі
    </a>
    @foreach (var genre in Model.Genres)
    {
        <a asp-action="Index" asp-route-genre="@genre"
           class="btn btn-sm @(genre == Model.ActiveGenre ? "btn-dark" : "btn-outline-dark")">
            @genre
        </a>
    }
</div>

@if (!Model.Movies.Any())
{
    <p class="text-muted">У цьому жанрі фільмів поки немає.</p>
}
else
{
    <div class="row row-cols-1 row-cols-md-3 g-3">
        @foreach (var movie in Model.Movies)
        {
            @* Partial View для картки фільму *@
            <div class="col">
                <partial name="_MovieCard" model="movie" />
            </div>
        }
    </div>
}
Views/Shared/_MovieCard.cshtml
@model MovieApp.Models.Movie

<div class="card h-100">
    <div class="card-body">
        <span class="badge bg-secondary float-end">@Model.Genre</span>
        <h5 class="card-title">@Model.Title</h5>
        <p class="card-text text-muted">@Model.Director, @Model.Year</p>
        <div class="d-flex align-items-center gap-2">
            <span class="text-warning"></span>
            <strong>@Model.Rating:F1</strong>
            @if (!Model.IsAvailable)
            {
                <span class="badge bg-secondary ms-auto">Недоступний</span>
            }
        </div>
    </div>
    <div class="card-footer bg-transparent">
        <a asp-controller="Movie" asp-action="Details" asp-route-id="@Model.Id"
           class="btn btn-sm btn-outline-primary">Детальніше</a>
        <form asp-controller="Movie" asp-action="Delete" asp-route-id="@Model.Id"
              method="post" class="d-inline ms-1">
            <button type="submit" class="btn btn-sm btn-outline-danger"
                    onclick="return confirm('Видалити «@Model.Title»?')">
                Видалити
            </button>
        </form>
    </div>
</div>
Views/Movie/Create.cshtml
@model MovieApp.Models.CreateMovieDto
@{ ViewData["Title"] = "Новий фільм"; }

<nav aria-label="breadcrumb">
    <ol class="breadcrumb">
        <li class="breadcrumb-item"><a asp-action="Index">Фільми</a></li>
        <li class="breadcrumb-item active">Додати фільм</li>
    </ol>
</nav>

<h1>Додати фільм</h1>

<div class="row">
    <div class="col-md-6">
        <form asp-action="Create" method="post">
            <div class="mb-3">
                <label asp-for="Title" class="form-label">Назва</label>
                <input asp-for="Title" class="form-control">
                <span asp-validation-for="Title" class="text-danger"></span>
            </div>
            <div class="row">
                <div class="col mb-3">
                    <label asp-for="Genre" class="form-label">Жанр</label>
                    <input asp-for="Genre" class="form-control">
                    <span asp-validation-for="Genre" class="text-danger"></span>
                </div>
                <div class="col mb-3">
                    <label asp-for="Year" class="form-label">Рік</label>
                    <input asp-for="Year" class="form-control" type="number">
                    <span asp-validation-for="Year" class="text-danger"></span>
                </div>
            </div>
            <div class="mb-3">
                <label asp-for="Director" class="form-label">Режисер</label>
                <input asp-for="Director" class="form-control">
                <span asp-validation-for="Director" class="text-danger"></span>
            </div>
            <div class="mb-3">
                <label asp-for="Rating" class="form-label">Рейтинг (0–10)</label>
                <input asp-for="Rating" class="form-control" type="number" step="0.1">
                <span asp-validation-for="Rating" class="text-danger"></span>
            </div>

            <div class="d-flex gap-2">
                <button type="submit" class="btn btn-primary">Зберегти</button>
                <a asp-action="Index" class="btn btn-outline-secondary">Скасувати</a>
            </div>
        </form>
    </div>
</div>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

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

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

Завдання 1.1. У _Layout.cshtml додайте відображення TempData["Success"] і TempData["Error"] як Bootstrap alerts. Переконайтеся що після успішного Create → Redirect вони з'являються на сторінці Index і зникають при наступному перезавантаженні.

Завдання 1.2. Поясніть практичну різницю: у якому випадку ViewBag.Count = 42 у Controller не буде доступним у View? (Підказка: коли відбувається redirect).

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

Завдання 2.1. Для MovieController.Index створіть MovieIndexViewModel що також містить: TopRatedMovie (Movie з найвищим рейтингом), AverageRating (decimal), GenreDistribution (Dictionary<string, int> — кількість фільмів у кожному жанрі). Відобразіть ці дані у View у вигляді статистики над таблицею.

Завдання 2.2. Реалізуйте Partial View _PaginationNav що приймає ViewModel з полями CurrentPage, TotalPages, Controller, Action. Відображає навігацію «← Попередня | 1 2 3 | Наступна →». Використайте у MovieController.Index без зміни Controller коду.

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

Завдання 3.1. Реалізуйте повноцінну сторінку порівняння фільмів: MovieController.Compare(int[] ids) — отримує масив id через query string, повертає CompareMoviesViewModel з властивостями: IReadOnlyList<Movie> Movies та Dictionary<string, object[]> AttributeComparison (для кожного атрибуту — масив значень для кожного фільму). View — порівняльна таблиця де рядки = атрибути, стовпці = фільми. Максимум 4 фільми для порівняння — якщо більше, повертайте BadRequest.


Резюме

  • View() → шукає Views/{Controller}/{Action}.cshtml; View(model) → передає typed model
  • ViewData — словник string→object, потребує cast, без IntelliSense
  • ViewBagdynamic wrapper над ViewData, без compile-time перевірки
  • ViewModel (strongly-typed) — рекомендований підхід: IntelliSense, compile-time перевірка, asp-for Tag Helper
  • TempData — cross-request (переживає redirect), ідеальний для Flash Messages
  • Partial Views: <partial name="_Name" model="item" /> — без змін порівняно з Razor Pages

У наступній статті — Filters: аспектно-орієнтоване програмування в MVC. Filter pipeline, 5 типів фільтрів, global vs action-level filters.