Razor PagesのOnPostDeleteAsyncでobjectを返さずJsonResultでJSONを返す実装例

ASP.NET Core の Razor Pages で削除処理を実装するとき、「OnPostDeleteAsync() の戻り値を object にして独自クラスをそのまま返したい(成功メッセージなど)」と考える場面があります。本記事では、Razor Pages の戻り値がなぜ IActionResult 前提なのかを整理し、AJAX で扱いやすい JsonResult を使った実装パターンを具体例つきで解説します。

目次

「object をそのまま返したい」発想が生まれる場面

削除ボタンを押したらページ遷移せずに一覧から対象行だけ消したい、削除成功ならトースト通知を出したい、といった UI を作るとき、フロント側(JavaScript)は「削除結果を表すデータ(JSON)」を期待します。

このとき C# 側で次のように書けたら楽に見えます。

public async Task<object> OnPostDeleteAsync()
{
    return new MySuccessMessage();
}

コントローラー(MVC/Web API)で「オブジェクトを返したら JSON になる」体験があると、Razor Pages でも同じことをしたくなります。しかし Razor Pages のハンドラーは設計思想が少し違い、期待どおりの自動変換にならない(または例外になる)ケースが多いのが実情です。

Razor Pages のハンドラー戻り値は「HTTP 応答」を返す設計

Razor Pages のハンドラーメソッド(OnGet... / OnPost... など)は、最終的には「HTTP 応答として何を返すか」を IActionResult を中心に組み立てる流れになっています。

典型的な戻り値の役割を整理すると次のとおりです。

戻り値の型用途実際に返るもの向いているケース
void / Task「ページを描画する」前提同じページの HTML(通常の Razor 描画)フォーム送信後も同じページを再表示したい
IActionResult / Task<IActionResult>HTTP 応答を明示的に制御リダイレクト、ページ、JSON、ステータスコードなど削除後にリダイレクトしたい、AJAX に JSON を返したい
object / Task<object>(Razor Pages 的には想定外)自動で JSON にならない/動作が不安定になりやすい推奨されない

ポイントは、Razor Pages は「ページ中心のフレームワーク」で、ハンドラーの戻り値は基本的に IActionResult を返す形に寄せるのが自然、という点です。

なぜ object を返しても「自動で JSON」にならないのか

よく混同されるのが、次の違いです。

  • コントローラー(特に Web API):オブジェクトを返すと、出力フォーマッター(JSON など)でシリアライズして返す設計が強い
  • Razor Pages:ページ描画(HTML)を基本に、必要に応じて JsonResult 等の IActionResult を返して応答を切り替える設計

つまり、Razor Pages で「オブジェクトを返す=HTTP 応答として JSON を返す」を成立させたいなら、Razor Pages が理解できる形(= IActionResult)にしてあげる必要があります。

結論:AJAX に JSON を返すなら JsonResult を返す

削除の結果(成功メッセージや削除した ID など)を JavaScript で受け取りたい場合は、JsonResult を返すのが王道です。

public async Task<IActionResult> OnPostDeleteAsync()
{
    return new JsonResult(new MySuccessMessage());
}

この形なら、クライアントは JSON を受け取り、画面の行を消す・トースト表示をする、といった UI 更新が素直に書けます。

「成功メッセージ用クラス」はこう作ると運用が楽

JSON の形を場当たり的に作ると、フロント側がすぐに複雑になります。削除処理のような「結果だけ返したい」API 風のレスポンスは、最低限次をそろえるのが実務的におすすめです。

フィールド型目的例
successbool成功/失敗を即判定true
messagestringユーザー向け文言(トースト等)"削除しました"
deletedIdint / string消す対象を特定123
redirectUrlstring(任意)必要なら遷移先を返す"/Items"

例えば、次のような record(または class)で統一すると、フロント側の条件分岐がシンプルになります。

public record DeleteResponse(bool Success, string Message, int DeletedId, string? RedirectUrl = null);

ハンドラーではこう返します。

public async Task<IActionResult> OnPostDeleteAsync(int id)
{
    // 例:削除成功
    var res = new DeleteResponse(true, "削除しました。", id);
    return new JsonResult(res);
}

ステータスコードも意識すると「バグが減る」

JSON だけ返していると、フロント側が「通信は成功したが処理は失敗」という状態を見落としがちです。API っぽくするなら、ステータスコードも揃えると保守性が上がります。

状況推奨ステータス返す JSON の例フロントの扱い
削除成功200{ success: true, message: "削除しました", deletedId: 123 }行を削除して通知
ID 不正・入力エラー400{ success: false, message: "入力が不正です" }メッセージ表示
対象が存在しない404{ success: false, message: "対象が見つかりません" }一覧の再読み込みを検討
権限がない403{ success: false, message: "権限がありません" }ログイン/権限導線
サーバー側例外500{ success: false, message: "削除に失敗しました" }汎用エラー表示

Razor Pages の PageModel は BadRequest() や NotFound() なども返せるため、JSON と組み合わせて「状態の表現」を統一すると運用が安定します。

実装例:OnPostDeleteAsync で削除して JSON を返す(EF Core 想定)

ここでは「一覧画面で削除 → 成功したら対象行を消す」という想定で、サーバー側の実装例を示します。ポイントは次の 3 つです。

  • 戻り値は Task<IActionResult>
  • 成功/失敗の JSON 形を揃える
  • 必要ならステータスコードも付ける

PageModel(.cshtml.cs)側

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;

public class IndexModel : PageModel
{
    private readonly AppDbContext _db;

    public IndexModel(AppDbContext db)
    {
        _db = db;
    }

    public record DeleteResponse(bool Success, string Message, int DeletedId);

    public async Task<IActionResult> OnPostDeleteAsync(int id)
    {
        if (id <= 0)
        {
            // 400 + JSON
            Response.StatusCode = 400;
            return new JsonResult(new { success = false, message = "削除対象のIDが不正です。" });
        }

        var entity = await _db.Items.FirstOrDefaultAsync(x => x.Id == id);
        if (entity is null)
        {
            // 404 + JSON
            Response.StatusCode = 404;
            return new JsonResult(new { success = false, message = "削除対象が見つかりませんでした。" });
        }

        _db.Items.Remove(entity);

        try
        {
            await _db.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException)
        {
            // 409(競合)なども選択肢
            Response.StatusCode = 409;
            return new JsonResult(new { success = false, message = "他のユーザー操作と競合しました。最新状態を確認してください。" });
        }
        catch
        {
            Response.StatusCode = 500;
            return new JsonResult(new { success = false, message = "削除に失敗しました。もう一度お試しください。" });
        }

        // 200 + JSON
        return new JsonResult(new DeleteResponse(true, "削除しました。", id));
    }
}

この例では Response.StatusCode を設定していますが、StatusCode(...) 系の結果や NotFound() を返すやり方でも構いません。大事なのは「フロントが失敗を判定できること」と「ユーザー向けメッセージが返ること」です。

Razor(.cshtml)側:削除ボタンとハンドラー呼び出しの作り方

Razor Pages では、OnPostDeleteAsync を呼ぶために、一般的に asp-page-handler="Delete" を使います。非AJAXなら通常のフォームでOKですが、AJAX化するなら「URL さえ分かれば fetch で呼べる」状態にしておくのがコツです。

例えば一覧の各行に削除ボタンを置くなら、次のようにデータ属性に ID を埋め込むと JavaScript が扱いやすくなります。

<!-- 例:一覧の各行に削除ボタンを配置 -->
<button type="button" class="js-delete" data-id="123">削除</button>

<!-- CSRF 対策:トークンをフォームか hidden に出しておく(後述) -->
<form id="antiforgeryForm" method="post"></form>

Razor Pages のフォーム送信では、通常はフレームワークが隠しトークンを埋め込む構成が多いです。AJAX で POST を投げる場合は、そのトークンをヘッダーなどで一緒に送るのが重要になります。

JavaScript 側:fetch で OnPostDeleteAsync を呼び、行を消す

削除は「GET 以外の操作」なので、CSRF 対策(アンチフォージェリトークン)が絡みます。プロジェクトの設定次第で細部は異なりますが、典型的には次のような流れになります。

  • ページ内にある __RequestVerificationToken(hidden)を取り出す
  • POST リクエストにトークンを載せる
  • ?handler=Delete を付けてハンドラーを指定

例として、フォームデータで id を送るパターンです。

async function deleteItem(id) {
  // hidden input から token を取る例(実際の配置に合わせて取得)
  const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]');
  const token = tokenInput ? tokenInput.value : '';

  const form = new URLSearchParams();
  form.append('id', String(id));

  const res = await fetch(`?handler=Delete`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
      'RequestVerificationToken': token
    },
    body: form.toString()
  });

  // まず HTTP として成功かを確認(400/404/500 ならここで落とせる)
  if (!res.ok) {
    let err;
    try { err = await res.json(); } catch { err = null; }
    const msg = err?.message ?? `削除に失敗しました(HTTP ${res.status})`;
    alert(msg);
    return;
  }

  const data = await res.json();
  if (!data.success) {
    alert(data.message ?? '削除に失敗しました。');
    return;
  }

  // 成功:画面から行を消す(例)
  const row = document.querySelector(`[data-row-id="${data.deletedId}"]`);
  if (row) row.remove();

  // 通知(例)
  console.log(data.message);
}

document.addEventListener('click', (e) => {
  const btn = e.target.closest('.js-delete');
  if (!btn) return;

  const id = Number(btn.dataset.id);
  if (!Number.isFinite(id) || id <= 0) return;

  if (!confirm('本当に削除しますか?')) return;

  deleteItem(id);
});

ここで ?handler=Delete としているのは、Razor Pages のハンドラー名(OnPostDeleteAsync の Delete 部分)を指定しているためです。URL の構成をこの形に揃えると、フロント側は「ページをAPIっぽく呼ぶ」感覚で実装できます。

「削除処理の結果を受け取って画面を更新したい」なら JSON が自然な理由

ページ遷移を前提にすると、削除成功後のメッセージ表示は TempData などを使って「次のページ表示時に出す」作りになりがちです。一方、AJAX 前提の UI では「その場で受け取って、その場で描画を変える」必要があります。

それぞれの向き・不向きを整理すると、判断が速くなります。

やりたいことおすすめの返し方メリット注意点
削除後に一覧へ戻したいRedirectToPage(...)実装が単純、JS が不要部分更新には不向き
削除後も同じページで表示更新したいnew JsonResult(...)部分更新しやすい、UX が良いCSRF 対策・フロント実装が必要
同じ削除APIを複数画面・外部からも使いたいAPI(Controller / Minimal API)に切り出し責務分離、再利用しやすい設計が増える(認証/認可など)

非AJAXで「成功メッセージ」を返したい場合の王道(TempData)

「オブジェクトを返したい」の動機が、実は「成功メッセージを出したい」だけなら、AJAX ではなく画面遷移(PRG: Post-Redirect-Get)を使うほうが自然なこともあります。

例えば削除後に一覧へ戻す場合、次のように TempData を使うと、リロードや二重送信にも強くなります。

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

public class IndexModel : PageModel
{
    [TempData]
    public string? FlashMessage { get; set; }

    public async Task<IActionResult> OnPostDeleteAsync(int id)
    {
        // ...削除処理...

        FlashMessage = "削除しました。";
        return RedirectToPage("./Index");
    }
}

.cshtml 側は次のように表示するだけです。

<!-- 例:メッセージ表示 -->
@if (!string.IsNullOrEmpty(Model.FlashMessage))
{
    <div class="notice">@Model.FlashMessage</div>
}

この方法なら「成功メッセージ用のオブジェクトを返す」必要がそもそもありません。用途がページ遷移中心なら、こちらのほうが保守しやすいことが多いです。

よくある落とし穴と、実務で効くチェックポイント

Razor Pages で JSON を返す実装はシンプルですが、現場では次のミスが頻出します。先に潰しておくと、デバッグ時間が一気に減ります。

症状ありがちな原因対処
ハンドラーが呼ばれない?handler=Delete を付けていない/asp-page-handler が違うURL とメソッド名の対応(OnPostDeleteAsync の Delete)を再確認
400 が返る(CSRF 関連)アンチフォージェリトークンが送れていないhidden の __RequestVerificationToken を取得し、ヘッダーへ設定
JSON のプロパティ名が想定と違うシリアライズ設定で camelCase になっている等フロント側を合わせるか、AddJsonOptions で方針を統一
削除成功なのに UI が更新されないフロントが deletedId を見ていない/DOM の特定が曖昧行の要素に data-row-id を付け、確実に一致させる
例外時に HTML が返ってきて JSON 解析で落ちる例外時に開発用エラーページ(HTML)が出ているサーバー側で try/catch し JSON を返す、フロント側で res.ok を先に見る

JSON の命名規則を統一する(camelCase か PascalCase か)

フロントとバックの境界で、地味に効いてくるのが命名規則です。C# は PascalCase が自然ですが、JavaScript では camelCase が一般的です。

プロジェクト全体で統一したい場合は、起動時の設定で System.Text.Json のオプションを揃えます(例)。

using System.Text.Json;

builder.Services
    .AddRazorPages()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    });

こうしておけば、レスポンスのプロパティ名がフロントで扱いやすくなります。逆に PascalCase のまま返す方針にするなら、フロント側でその前提に合わせるだけです。どちらでもいいので、途中で混在させないのが重要です。

「削除に成功したのに、実は削除されていない」を防ぐ

削除処理で怖いのは、フロントは成功表示したのに DB では削除が失敗していた、逆に DB は削除できたのに UI が更新されずユーザーが再操作してしまう、といったズレです。

実務では次の 2 点を意識すると安定します。

  • サーバー側:削除結果(成功/失敗)を必ず JSON で返す。例外も握りつぶさず「失敗」を返す
  • フロント側:res.ok(HTTP)と data.success(業務結果)の両方で判定する

この二段構えにするだけで「表示は成功なのに実態が違う」事故が減ります。

まとめ:Razor Pages で「オブジェクトをそのまま返したい」なら、返すべきは IActionResult

Razor Pages のハンドラーで object を返して自動的に JSON にする、という使い方は基本的に噛み合いません。Razor Pages はページ中心の設計で、戻り値は IActionResult を返して HTTP 応答を明示するのが前提だからです。

削除処理(OnPostDeleteAsync)の結果をクライアント側で受け取り、画面を更新したいなら、次の形に寄せるのが最も堅実です。

  • 戻り値は Task<IActionResult>
  • AJAX 用の応答は new JsonResult(...)
  • 必要ならステータスコードも揃える(400/404/409/500 など)
  • レスポンスの形(success/message/id)を統一してフロントを簡単にする

この方針で作ると、「削除成功時の行削除」「失敗時のメッセージ表示」「競合や権限エラーの扱い」まで一貫した実装になり、Razor Pages でも快適に AJAX ベースの UI を構築できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次