Razor Pages

PageModel: логіка сторінки Razor Pages

Детальний розгляд PageModel в Razor Pages: OnGet/OnPost/OnPutAsync handler-методи, [BindProperty] і [BindProperty(SupportsGet)], ModelState і повідомлення валідації, return Page/RedirectToPage/NotFound/Content, TempData vs ViewData vs властивості PageModel, Page Handlers для кількох форм.

PageModel: логіка сторінки Razor Pages

PageModel — це серце кожної Razor Page. Це клас, де живе вся логіка сторінки: обробка GET/POST запитів, отримання даних, перевірка валідації, перенаправлення. Ні більше, ні менше.

Якщо ви звикли до Minimal API, ось найближча аналогія:

// Minimal API: lambda-хендлер
app.MapGet("/products/{id}", async (int id, ProductService svc) =>
{
    var product = await svc.GetByIdAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

// Razor Pages: PageModel
public class DetailsModel : PageModel
{
    private readonly ProductService _svc;
    public DetailsModel(ProductService svc) => _svc = svc;

    public Product? Product { get; private set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Product = await _svc.GetByIdAsync(id);
        return Product is null ? NotFound() : Page();
    }
}

Ті самі концепції: DI через конструктор, отримання даних, повернення результату. Але замість Results.*Page(), NotFound(), RedirectToPage().


Handler-методи: OnGet, OnPost та інші

Razor Pages автоматично викликає методи PageModel залежно від HTTP-методу запиту:

HTTP MethodМетод PageModel
GETOnGet() / OnGetAsync()
POSTOnPost() / OnPostAsync()
PUTOnPut() / OnPutAsync()
DELETEOnDelete() / OnDeleteAsync()
PATCHOnPatch() / OnPatchAsync()
HEADOnHead() / OnHeadAsync()
Pages/Products/Index.cshtml.cs
public class IndexModel : PageModel
{
    private readonly ProductService _svc;
    private readonly ILogger<IndexModel> _logger;

    public IndexModel(ProductService svc, ILogger<IndexModel> logger)
    {
        _svc = svc;
        _logger = logger;
    }

    // Власивість доступна у .cshtml через Model.Products
    public List<Product> Products { get; private set; } = [];

    // ─── GET /products ─────────────────────────────────────────
    // Повертає void або Task якщо завжди → Page()
    public async Task OnGetAsync()
    {
        _logger.LogInformation("Loading products list");
        Products = await _svc.GetAllAsync();
        // Implicit return Page() — завжди рендеримо Index.cshtml
    }
}
Pages/Products/Create.cshtml.cs
public class CreateModel : PageModel
{
    private readonly ProductService _svc;

    public CreateModel(ProductService svc) => _svc = svc;

    // ─── GET /products/create ───────────────────────────────────
    public void OnGet()
    {
        // Просто показуємо порожню форму — нічого не робимо
        // (можна заповнити dropdown-и, категорії тощо)
    }

    // ─── POST /products/create ──────────────────────────────────
    // Повертає IActionResult якщо можливі різні результати (Page або Redirect)
    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            // Є помилки валідації — показуємо форму знову
            return Page();
        }

        await _svc.CreateAsync(Input);
        // Успіх → редирект на список (Post-Redirect-Get патерн)
        return RedirectToPage("./Index");
    }
}

BindProperty: зв'язування даних з форм

[BindProperty] — аналог [FromBody] у Minimal API, але для HTML-форм. Атрибут автоматично наповнює властивість даними з POST-форми:

Pages/Products/Create.cshtml.cs
public class CreateModel : PageModel
{
    // BindProperty: автоматично заповнюється при POST
    // Дані з HTML form input="Product.Name" → цей об'єкт
    [BindProperty]
    public CreateProductInput Input { get; set; } = new();

    public async Task<IActionResult> OnPostAsync()
    {
        // Input вже заповнений даними з форми
        // ModelState вже перевірений за DataAnnotations
        if (!ModelState.IsValid)
            return Page();

        await _svc.CreateAsync(
            new Product { Name = Input.Name, Price = Input.Price });

        return RedirectToPage("./Index");
    }
}

// DTO для вводу — з DataAnnotations для валідації
public class CreateProductInput
{
    [Required(ErrorMessage = "Назва обов'язкова")]
    [MaxLength(100, ErrorMessage = "Максимум 100 символів")]
    public string Name { get; set; } = "";

    [Required]
    [Range(0.01, 999_999, ErrorMessage = "Ціна має бути від 0.01 до 999,999")]
    public decimal Price { get; set; }

    [MaxLength(500)]
    public string? Description { get; set; }
}

BindProperty(SupportsGet = true)

За замовчуванням [BindProperty] працює тільки з POST. Для GET (параметри пошуку, пайджинація) — SupportsGet = true:

Pages/Products/Index.cshtml.cs
public class IndexModel : PageModel
{
    // Аналог [FromQuery] у Minimal API
    // Заповнюється з ?search=coffee&page=2
    [BindProperty(SupportsGet = true)]
    public string? Search { get; set; }

    [BindProperty(SupportsGet = true)]
    public int Page { get; set; } = 1;

    [BindProperty(SupportsGet = true)]
    public int PageSize { get; set; } = 10;

    public List<Product> Products { get; private set; } = [];
    public int TotalPages { get; private set; }

    public async Task OnGetAsync()
    {
        // Search, Page, PageSize вже заповнені з query string
        var (products, total) = await _svc.SearchAsync(Search, Page, PageSize);
        Products = products;
        TotalPages = (int)Math.Ceiling(total / (double)PageSize);
    }
}
@page дозволяє пошуковий запит бути у URL
<!-- Форма пошуку — GET запит -->
<form method="get">
    <input asp-for="Search" class="form-control" placeholder="Пошук...">
    <button type="submit">Знайти</button>
</form>

<!-- Пагінація як посилання -->
@for (int i = 1; i <= Model.TotalPages; i++)
{
    <a asp-page="./Index"
       asp-route-search="@Model.Search"
       asp-route-page="@i"
       class="btn @(i == Model.Page ? "btn-primary" : "btn-outline-primary")">
        @i
    </a>
}

Параметри з маршруту: @page "{id:int}"

Pages/Products/Edit.cshtml.cs
// @page "{id:int}" у .cshtml файлі
// → /products/edit/42
public class EditModel : PageModel
{
    [BindProperty]
    public EditProductInput Input { get; set; } = new();

    // id приходить з маршруту — як параметр методу
    public async Task<IActionResult> OnGetAsync(int id)
    {
        var product = await _svc.GetByIdAsync(id);
        if (product is null) return NotFound();

        // Заповнюємо форму поточними значеннями
        Input = new EditProductInput
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Description = product.Description
        };

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid) return Page();

        var success = await _svc.UpdateAsync(id, Input);
        return success ? RedirectToPage("./Index") : NotFound();
    }
}

ModelState: перевірка валідації

ModelState в Razor Pages — той самий ModelState що і в MVC. Він заповнюється автоматично на основі DataAnnotations до виклику OnPost():

public async Task<IActionResult> OnPostAsync()
{
    // ModelState.IsValid = false якщо:
    // - порушено [Required], [MaxLength], [Range], [EmailAddress] тощо
    // - [BindProperty] мав помилку парсингу (наприклад, "abc" у [Range] decimal)
    if (!ModelState.IsValid)
    {
        // Опціонально: додаємо власні помилки
        // ModelState.AddModelError("Input.Name", "Ця назва вже зайнята");

        // Повертаємо ту саму сторінку з помилками
        // Tag Helper <span asp-validation-for="..."> показає їх автоматично
        return Page();
    }

    // Ручна валідація — якщо щось не можна перевірити через атрибути
    var isDuplicate = await _svc.ExistsAsync(Input.Name);
    if (isDuplicate)
    {
        // Додаємо помилку для конкретного поля
        ModelState.AddModelError(
            "Input.Name",
            $"Товар з назвою '{Input.Name}' вже існує");
        return Page();
    }

    // Також можна додати загальну помилку (не прив'язану до поля)
    // ModelState.AddModelError(string.Empty, "Щось пішло не так");

    await _svc.CreateAsync(Input);
    return RedirectToPage("./Index");
}
Pages/Products/Create.cshtml — показ помилок
<form method="post">
    <div class="mb-3">
        <label asp-for="Input.Name" class="form-label">Назва</label>
        <input asp-for="Input.Name" class="form-control">
        <!-- Автоматично показує повідомлення помилки для Input.Name -->
        <span asp-validation-for="Input.Name" class="text-danger"></span>
    </div>

    <!-- Загальні помилки (не прив'язані до поля) -->
    <div asp-validation-summary="All" class="text-danger"></div>

    <button type="submit" class="btn btn-primary">Зберегти</button>
</form>

Повернення результатів: аналоги Results.*

В Minimal API: Results.Ok(), Results.NotFound(), Results.Redirect(). У Razor Pages:

// Рендерити поточну сторінку (.cshtml)
// Аналог: "просто повернути 200 OK з HTML"
return Page();

// Редирект на іншу Razor Page (по назві файлу відносно Pages/)
return RedirectToPage("./Index");             // Pages/Products/Index
return RedirectToPage("/Index");              // Pages/Index (корінь)
return RedirectToPage("./Details", new { id = product.Id }); // З параметрами

// Редирект на URL
return Redirect("/some/url");
return LocalRedirect("/safe-local-url");      // Захищений від open redirect

// HTTP статуси — ті самі що і в Minimal API
return NotFound();                            // 404
return BadRequest();                          // 400
return BadRequest("Невірний запит");
return Forbid();                              // 403
return Unauthorized();                        // 401
return StatusCode(429);                       // Довільний статус

// Повернути JSON (якщо потрібно змішати Razor Pages і API)
return new JsonResult(new { id = 1, name = "test" });

// Повернути файл
return File(fileBytes, "application/pdf", "report.pdf");
return PhysicalFile("/path/to/file.pdf", "application/pdf");

// Порожня відповідь
return new EmptyResult();                     // 200 без тіла

TempData: повідомлення після редиректу

TempData — дані що виживають один редирект. Ідеально для flash-повідомлень ("Збережено!"):

Pages/Products/Create.cshtml.cs
public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid) return Page();
    await _svc.CreateAsync(Input);

    // Зберігаємо повідомлення — воно буде доступне після одного Redirect
    TempData["SuccessMessage"] = $"Товар '{Input.Name}' успішно створено!";
    return RedirectToPage("./Index");
}
Pages/Products/Index.cshtml.cs
public async Task OnGetAsync()
{
    Products = await _svc.GetAllAsync();
    // TempData["SuccessMessage"] автоматично доступний у view
    // після першого прочитання — видаляється
}
Pages/Products/Index.cshtml
@if (TempData["SuccessMessage"] is string message)
{
    <div class="alert alert-success alert-dismissible">
        @message
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    </div>
}

ViewData: дані для Layout

ViewData — словник даних для передачі між PageModel і View (включаючи Layout). Найпоширеніший випадок — title сторінки:

PageModel
public async Task OnGetAsync()
{
    ViewData["Title"] = "Список товарів";
    ViewData["ActiveMenu"] = "products"; // Для підсвітки активного пункту меню
    Products = await _svc.GetAllAsync();
}
_Layout.cshtml
<title>@ViewData["Title"] — MyShop</title>
<nav>
    <a class="@(ViewData["ActiveMenu"]?.ToString() == "products" ? "active" : "")">Товари</a>
</nav>
Використовуйте властивості PageModel для даних бізнес-логіки (список товарів, об'єкт форми), TempData для flash-повідомлень між сторінками, ViewData для метаданих та даних Layout.

Page Handlers: кілька форм на одній сторінці

Що якщо на одній сторінці є дві форми? Наприклад, форма редагування товару і форма видалення. Page Handlers — це суфікс до OnPost:

Pages/Products/Details.cshtml.cs
public class DetailsModel : PageModel
{
    [BindProperty]
    public EditProductInput EditInput { get; set; } = new();

    public Product? Product { get; private set; }

    public async Task OnGetAsync(int id)
    {
        Product = await _svc.GetByIdAsync(id);
        if (Product is not null)
            EditInput = new EditProductInput { Name = Product.Name, Price = Product.Price };
    }

    // Обробляє POST з ?handler=Update (або asp-page-handler="Update" у формі)
    public async Task<IActionResult> OnPostUpdateAsync(int id)
    {
        if (!ModelState.IsValid) return Page();
        await _svc.UpdateAsync(id, EditInput);
        TempData["Success"] = "Оновлено!";
        return RedirectToPage("./Details", new { id });
    }

    // Обробляє POST з ?handler=Delete (або asp-page-handler="Delete" у формі)
    public async Task<IActionResult> OnPostDeleteAsync(int id)
    {
        await _svc.DeleteAsync(id);
        TempData["Success"] = "Товар видалено";
        return RedirectToPage("./Index");
    }
}
Pages/Products/Details.cshtml — дві форми
<!-- Форма редагування — asp-page-handler="Update" -->
<form method="post" asp-page-handler="Update">
    <input asp-for="EditInput.Name">
    <input asp-for="EditInput.Price">
    <button type="submit" class="btn btn-primary">Зберегти</button>
</form>

<!-- Форма видалення — asp-page-handler="Delete" -->
<form method="post" asp-page-handler="Delete">
    <button type="submit" class="btn btn-danger"
            onclick="return confirm('Видалити товар?')">
        Видалити
    </button>
</form>

При натисканні кнопки з asp-page-handler="Update" — виконується OnPostUpdateAsync, при "Delete" — OnPostDeleteAsync.


DI у PageModel: різні способи

Всі способи ін'єкції залежностей
public class ProductsModel : PageModel
{
    // ─── Спосіб 1: через конструктор (рекомендовано) ──────────────
    private readonly ProductService _svc;
    private readonly ILogger<ProductsModel> _logger;
    private readonly IMemoryCache _cache;

    public ProductsModel(
        ProductService svc,
        ILogger<ProductsModel> logger,
        IMemoryCache cache)
    {
        _svc = svc;
        _logger = logger;
        _cache = cache;
    }

    // ─── Спосіб 2: [FromServices] — для опціональних залежностей ──
    // Аналог [FromServices] у Minimal API
    public async Task OnGetAsync(
        [FromServices] IExternalApiClient apiClient)
    {
        // apiClient доступний тільки у цьому методі
        var data = await apiClient.FetchAsync();
    }

    // ─── Спосіб 3: HttpContext.RequestServices ──────────────────
    // (Antipattern — уникайте якщо можливо)
    public async Task OnGetFallbackAsync()
    {
        var svc = HttpContext.RequestServices.GetRequiredService<ProductService>();
    }
}

Повний приклад: CRUD без зайвого

Pages/Products/Index.cshtml.cs
namespace MyApp.Pages.Products;

public class IndexModel : PageModel
{
    private readonly ProductService _svc;

    public IndexModel(ProductService svc) => _svc = svc;

    [BindProperty(SupportsGet = true)]
    public string? Search { get; set; }

    [BindProperty(SupportsGet = true)]
    public int Page { get; set; } = 1;

    public List<Product> Products { get; private set; } = [];
    public int TotalCount { get; private set; }
    public int PageSize { get; } = 10;
    public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);

    public string? SuccessMessage =>
        TempData.Peek("SuccessMessage") as string;

    public async Task OnGetAsync()
    {
        (Products, TotalCount) = await _svc.SearchPagedAsync(Search, Page, PageSize);
    }

    // Видалення прямо зі сторінки списку
    public async Task<IActionResult> OnPostDeleteAsync(int id)
    {
        await _svc.DeleteAsync(id);
        TempData["SuccessMessage"] = "Товар видалено";
        return RedirectToPage(); // Редирект на ту саму сторінку
    }
}

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

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

Завдання 1.1. Створіть сторінку Pages/Categories/Index.cshtml з PageModel. OnGetAsync завантажує список категорій із сервісу. OnPostDeleteAsync(int id) видаляє категорію і повертає RedirectToPage(). Після видалення — flash-повідомлення через TempData.

Завдання 1.2. Реалізуйте пошук на Pages/Products/Index.cshtml: [BindProperty(SupportsGet = true)] string? Search, [BindProperty(SupportsGet = true)] int Page = 1. При OnGetAsync — фільтруйте товари за пошуковим запитом і реалізуйте пагінацію. URL при пошуку: /products?search=coffee&page=2.

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

Завдання 2.1. Реалізуйте Pages/Products/Edit.cshtml.cs з двома handlers: OnPostSaveAsync(int id) — зберегти зміни, OnPostDuplicateAsync(int id) — створити копію товару з новою назвою. Обидва handler повинні мати власні кнопки у View з asp-page-handler.

Завдання 2.2. Додайте до Pages/Products/Create.cshtml.cs моментальну перевірку дублікату: у OnPostAsync перед збереженням перевіряйте через _svc.ExistsAsync(Input.Name) і якщо існує — ModelState.AddModelError("Input.Name", "Вже існує") + return Page(). Напишіть інтеграційний тест що перевіряє цю поведінку.

Copyright © 2026