ASP.NET MVC - How to Preserve ModelState Errors Across RedirectToAction?
Asked Answered
M

12

107

I have the following two action methods (simplified for question):

[HttpGet]
public ActionResult Create(string uniqueUri)
{
   // get some stuff based on uniqueuri, set in ViewData.  
   return View();
}

[HttpPost]
public ActionResult Create(Review review)
{
   // validate review
   if (validatedOk)
   {
      return RedirectToAction("Details", new { postId = review.PostId});
   }  
   else
   {
      ModelState.AddModelError("ReviewErrors", "some error occured");
      return RedirectToAction("Create", new { uniqueUri = Request.RequestContext.RouteData.Values["uniqueUri"]});
   }   
}

So, if the validation passes, i redirect to another page (confirmation).

If an error occurs, i need to display the same page with the error.

If i do return View(), the error is displayed, but if i do return RedirectToAction (as above), it loses the Model errors.

I'm not surprised by the issue, just wondering how you guys handle this?

I could of course just return the same View instead of the redirect, but i have logic in the "Create" method which populates the view data, which i'd have to duplicate.

Any suggestions?

Margarine answered 10/1, 2011 at 0:45 Comment(4)
I solve this problem by not using the Post-Redirect-Get pattern for validation errors. I just use View(). It's perfectly valid to do that instead of jumping through a bunch of hoops - and redirect messes with your browser history.Kirkham
And in addition to what @JimmyBogard has said, extract out the logic in the Create method which populates ViewData and call it in the Create GET method and also in the failed validation branch in the Create POST method.Diaspora
Agreed, avoiding the problem is one way of solving it. I have some logic to populate stuff in my Create view, I just put it in some method populateStuff that I call in both the GET and the fail POST.Hydroscope
@JimmyBogard I disagree, if you post to an action and then return the view you run into the issue where if the user hits refresh they get the warning about wanting to initiate that post again.Tubercle
G
53

You need to have the same instance of Review on your HttpGet action. To do that you should save an object Review review in temp variable on your HttpPost action and then restore it on HttpGet action.

[HttpGet]
public ActionResult Create(string uniqueUri)
{
   //Restore
   Review review = TempData["Review"] as Review;            

   // get some stuff based on uniqueuri, set in ViewData.  
   return View(review);
}
[HttpPost]
public ActionResult Create(Review review)
{
   //Save your object
   TempData["Review"] = review;

   // validate review
   if (validatedOk)
   {
      return RedirectToAction("Details", new { postId = review.PostId});
   }  
   else
   {
      ModelState.AddModelError("ReviewErrors", "some error occured");
      return RedirectToAction("Create", new { uniqueUri = Request.RequestContext.RouteData.Values["uniqueUri"]});
   }   
}

If you want this to work even if the browser is refreshed after the first execution of the HttpGet action, you could do this:

  Review review = TempData["Review"] as Review;  
  TempData["Review"] = review;

Otherwise on refresh button object review will be empty because there wouldn't be any data in TempData["Review"].

Geithner answered 10/1, 2011 at 0:54 Comment(9)
Excellent. And a big +1 for mentioning the refresh issue. This is the most complete answer so i'll accept it, thanks a bunch. :)Margarine
This doesn't really answer the question in the title. ModelState isn't preserved and that has ramifications such as input HtmlHelpers not preserving user entry. This is almost a workaround.Barbusse
I ended up doing what @Wim suggested in his answer.Margarine
No need to cast. TempData["Review"] = TempData["Review"]; works just as well (redundant as it may look :)Immunochemistry
@jfar, I agree, this answer doesn't work and does not persist the ModelState. However, if you modify it so it does something like TempData["ModelState"] = ModelState; and restore with ModelState.Merge((ModelStateDictionary)TempData["ModelState"]);, then it would workImpotence
Could you not just return Create(uniqueUri) when validation fails on the POST? As ModelState values take precedence over the ViewModel passed in to the view, the posted data should still remain.Aitken
What will happen if the user opens the form twice (one in each tab), breaks validation in first form and refreshes the second form. Won't the second form get temp data from the first one?Mideast
I dont know if it existed when this answer was given but rather than pulling the item out of tempdata and re-adding it you should use var review = TempData.Peek("Review") as ReviewSolent
I wasn't able to make this work until I realized the session ID kept changing. This helped me solve that: https://mcmap.net/q/205318/-sessionid-keeps-changing-in-asp-net-mvc-whyDescribe
I
88

I had to solve this problem today myself, and came across this question.

Some of the answers are useful (using TempData), but don't really answer the question at hand.

The best advice I found was on this blog post:

http://www.jefclaes.be/2012/06/persisting-model-state-when-using-prg.html

Basically, use TempData to save and restore the ModelState object. However, it's a lot cleaner if you abstract this away into attributes.

E.g.

public class SetTempDataModelStateAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        base.OnActionExecuted(filterContext);         
        filterContext.Controller.TempData["ModelState"] = 
           filterContext.Controller.ViewData.ModelState;
    }
}

public class RestoreModelStateFromTempDataAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        base.OnActionExecuting(filterContext);
        if (filterContext.Controller.TempData.ContainsKey("ModelState"))
        {
            filterContext.Controller.ViewData.ModelState.Merge(
                (ModelStateDictionary)filterContext.Controller.TempData["ModelState"]);
        }
    }
}

Then as per your example, you could save / restore the ModelState like so:

[HttpGet]
[RestoreModelStateFromTempData]
public ActionResult Create(string uniqueUri)
{
    // get some stuff based on uniqueuri, set in ViewData.  
    return View();
}

[HttpPost]
[SetTempDataModelState]
public ActionResult Create(Review review)
{
    // validate review
    if (validatedOk)
    {
        return RedirectToAction("Details", new { postId = review.PostId});
    }  
    else
    {
        ModelState.AddModelError("ReviewErrors", "some error occured");
        return RedirectToAction("Create", new { uniqueUri = Request.RequestContext.RouteData.Values["uniqueUri"]});
    }   
}

If you also want to pass the model along in TempData (as bigb suggested) then you can still do that too.

Impotence answered 19/8, 2012 at 4:30 Comment(5)
Thank you. We implemented something similar to your approach. gist.github.com/ferventcoder/4735084Neela
@Impotence - great solution, but I ran into a problem using it in combination with repeating Partial Views, I posted the question here: #28372830Aegir
Warning - if the page is served all in one request (and not broken up via AJAX), you are asking for trouble using this solution since TempData is preserved through to the next request. For example: you enter search criteria into one page, then PRG to search results, then click a link to directly navigate back to the search page, the original search values will be repopulated. Other weird and sometimes hard to reproduce behavior pops up, too.Tunis
I wasn't able to make this work until I realized the session ID kept changing. This helped me solve that: https://mcmap.net/q/205318/-sessionid-keeps-changing-in-asp-net-mvc-whyDescribe
Q: what is NextRequest and TempData behavior when there are multiple browser-tabs making (multiple/simultaneous) requests?Craving
G
53

You need to have the same instance of Review on your HttpGet action. To do that you should save an object Review review in temp variable on your HttpPost action and then restore it on HttpGet action.

[HttpGet]
public ActionResult Create(string uniqueUri)
{
   //Restore
   Review review = TempData["Review"] as Review;            

   // get some stuff based on uniqueuri, set in ViewData.  
   return View(review);
}
[HttpPost]
public ActionResult Create(Review review)
{
   //Save your object
   TempData["Review"] = review;

   // validate review
   if (validatedOk)
   {
      return RedirectToAction("Details", new { postId = review.PostId});
   }  
   else
   {
      ModelState.AddModelError("ReviewErrors", "some error occured");
      return RedirectToAction("Create", new { uniqueUri = Request.RequestContext.RouteData.Values["uniqueUri"]});
   }   
}

If you want this to work even if the browser is refreshed after the first execution of the HttpGet action, you could do this:

  Review review = TempData["Review"] as Review;  
  TempData["Review"] = review;

Otherwise on refresh button object review will be empty because there wouldn't be any data in TempData["Review"].

Geithner answered 10/1, 2011 at 0:54 Comment(9)
Excellent. And a big +1 for mentioning the refresh issue. This is the most complete answer so i'll accept it, thanks a bunch. :)Margarine
This doesn't really answer the question in the title. ModelState isn't preserved and that has ramifications such as input HtmlHelpers not preserving user entry. This is almost a workaround.Barbusse
I ended up doing what @Wim suggested in his answer.Margarine
No need to cast. TempData["Review"] = TempData["Review"]; works just as well (redundant as it may look :)Immunochemistry
@jfar, I agree, this answer doesn't work and does not persist the ModelState. However, if you modify it so it does something like TempData["ModelState"] = ModelState; and restore with ModelState.Merge((ModelStateDictionary)TempData["ModelState"]);, then it would workImpotence
Could you not just return Create(uniqueUri) when validation fails on the POST? As ModelState values take precedence over the ViewModel passed in to the view, the posted data should still remain.Aitken
What will happen if the user opens the form twice (one in each tab), breaks validation in first form and refreshes the second form. Won't the second form get temp data from the first one?Mideast
I dont know if it existed when this answer was given but rather than pulling the item out of tempdata and re-adding it you should use var review = TempData.Peek("Review") as ReviewSolent
I wasn't able to make this work until I realized the session ID kept changing. This helped me solve that: https://mcmap.net/q/205318/-sessionid-keeps-changing-in-asp-net-mvc-whyDescribe
B
7

Why not create a private function with the logic in the "Create" method and calling this method from both the Get and the Post method and just do return View().

Blackguardly answered 10/1, 2011 at 11:11 Comment(3)
This is what I do too, only instead of having a private function, I simply have my POST method call the GET method on error (i.e. return Create(new { uniqueUri = ... });. Your logic stays DRY (much like calling RedirectToAction), but without the issues carried by redirecting, such as losing your ModelState.Immunochemistry
@DanielLiuzzi: doing it that way will not change the URL. So you end with url something like "/controller/create/".Bortz
@SkorunkaFrantišek And that's exactly the point. The question states If an error occurs, I need to display the same page with the error. In this context, it is perfectly acceptable (and preferable IMO) that the URL does NOT change if the same page is displayed. Also, one advantage this approach has is that if the error in question is not a validation error but a system error (DB timeout for example) it allows the user to simply refresh the page to resubmit the form.Immunochemistry
S
6

Microsoft removed the ability to store complex data types in TempData, therefore the previous answers no longer work; you can only store simple types like strings. I have altered the answer by @asgeo1 to work as expected.

public class SetTempDataModelStateAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        base.OnActionExecuted(filterContext);

        var controller = filterContext.Controller as Controller;
        var modelState = controller?.ViewData.ModelState;
        if (modelState != null)
        {
            var listError = modelState.Where(x => x.Value.Errors.Any())
                .ToDictionary(m => m.Key, m => m.Value.Errors
                .Select(s => s.ErrorMessage)
                .FirstOrDefault(s => s != null));
            controller.TempData["KEY HERE"] = JsonConvert.SerializeObject(listError);
        }
    }
}


public class RestoreModelStateFromTempDataAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        base.OnActionExecuting(filterContext);

        var controller = filterContext.Controller as Controller;
        var tempData = controller?.TempData?.Keys;
        if (controller != null && tempData != null)
        {
            if (tempData.Contains("KEY HERE"))
            {
                var modelStateString = controller.TempData["KEY HERE"].ToString();
                var listError = JsonConvert.DeserializeObject<Dictionary<string, string>>(modelStateString);
                var modelState = new ModelStateDictionary();
                foreach (var item in listError)
                {
                    modelState.AddModelError(item.Key, item.Value ?? "");
                }

                controller.ViewData.ModelState.Merge(modelState);
            }
        }
    }
}

From here, you can simply add the required data annotation on a controller method as needed.

[RestoreModelStateFromTempDataAttribute]
[HttpGet]
public async Task<IActionResult> MethodName()
{
}


[SetTempDataModelStateAttribute]
[HttpPost]
public async Task<IActionResult> MethodName()
{
    ModelState.AddModelError("KEY HERE", "ERROR HERE");
}
Satinwood answered 7/9, 2018 at 16:30 Comment(2)
Works perfectly!. Edited the answer to fix a small bracket error when pasting the code.Husha
This is the only answer here that works in .net core 2.1.Toreutic
D
5

I could use TempData["Errors"]

TempData are passed accross actions preserving data 1 time.

Domela answered 10/1, 2011 at 0:52 Comment(0)
G
4

I suggest you return the view, and avoid duplication via an attribute on the action. Here is an example of populating to view data. You could do something similar with your create method logic.

public class GetStuffBasedOnUniqueUriAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var filter = new GetStuffBasedOnUniqueUriFilter();

        filter.OnActionExecuting(filterContext);
    }
}


public class GetStuffBasedOnUniqueUriFilter : IActionFilter
{
    #region IActionFilter Members

    public void OnActionExecuted(ActionExecutedContext filterContext)
    {

    }

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        filterContext.Controller.ViewData["somekey"] = filterContext.RouteData.Values["uniqueUri"];
    }

    #endregion
}

Here is an example:

[HttpGet, GetStuffBasedOnUniqueUri]
public ActionResult Create()
{
    return View();
}

[HttpPost, GetStuffBasedOnUniqueUri]
public ActionResult Create(Review review)
{
    // validate review
    if (validatedOk)
    {
        return RedirectToAction("Details", new { postId = review.PostId });
    }

    ModelState.AddModelError("ReviewErrors", "some error occured");
    return View(review);
}
Ghost answered 10/1, 2011 at 0:56 Comment(12)
How is this a bad idea? I think the attribute avoids the need to use another action because both actions can use the attribute to load to ViewData.Ghost
Please take a look at Post/Redirect/Get pattern: en.wikipedia.org/wiki/Post/Redirect/GetGoodale
That is normally used after model validation is satisfied, to prevent further posts to the same form on refresh. But if the form has issues, then it needs to be corrected and reposted anyway. This question deals with handling model errors.Ghost
Filters are for reusable code on actions, especially useful for putting things in ViewData. TempData is just a workaround.Ghost
This is great for simple pages and views +1. The post/redirect/get comment is just a pattern, not a golden rule of life, so I choose to ignore that wiki link. The problem is when the view uses one ViewModel but posts to an action with separate models. The issue now is to display the page as it was... but how?Gombroon
@ppumkin maybe try posting with ajax so that you don't have a hard time rebuilding your view server side.Ghost
Yea. That's what I did in the end. Solved everything. Good to know I chose a good solution. Thanks for the comment. +1Gombroon
In the case of an error you're not redirecting, so it is not PRG pattern, and will suffer from the problems of not using PRG. The question stated "across redirecttoaction"Oenomel
@Oenomel what is PRG?Ghost
@Ghost Post Redirect Get. DreamSonic linked the wiki definition, and this is a standard in most web applications to avoid a POST from being part of the browser history such that a user is confused or makes a mistake by refreshing or going back and resubmitting the POST unintentionally. The code in the question implements the PRG pattern, with the POST always returning a redirect.Oenomel
@Oenomel Ah yes I recognise post redirect get just wasn't used to it being used as an acronym. Thanks for letting me know.Ghost
@CRise And FYI I didn't downvote. I think for some they may find it acceptable to diverge from PRG in the case of an error, so I think your answer may be a reasonable compromise for some. I just personally would avoid it or be mindful of the cons.Oenomel
G
2

I have a method that adds model state to temp data. I then have a method in my base controller that checks temp data for any errors. If it has them, it adds them back to ModelState.

Goodden answered 10/1, 2011 at 1:27 Comment(0)
R
1

My scenario is a little bit more complicated as I am using the PRG pattern so my ViewModel ("SummaryVM") is in TempData, and my Summary screen displays it. There is a small form on this page to POST some info to another Action. The complication has come from a requirement for the user to edit some fields in SummaryVM on this page.

Summary.cshtml has the validation summary which will catch ModelState errors that we'll create.

@Html.ValidationSummary()

My form now needs to POST to a HttpPost action for Summary(). I have another very small ViewModel to represent edited fields, and modelbinding will get these to me.

The new form:

@using (Html.BeginForm("Summary", "MyController", FormMethod.Post))
{
    @Html.Hidden("TelNo") @* // Javascript to update this *@

and the action...

[HttpPost]
public ActionResult Summary(EditedItemsVM vm)

In here I do some validation and I detect some bad input, so I need to return to the Summary page with the errors. For this I use TempData, which will survive a redirection. If there is no issue with the data, I replace the SummaryVM object with a copy (but with the edited fields changed of course) then do a RedirectToAction("NextAction");

// Telephone number wasn't in the right format
List<string> listOfErrors = new List<string>();
listOfErrors.Add("Telephone Number was not in the correct format. Value supplied was: " + vm.TelNo);
TempData["SummaryEditedErrors"] = listOfErrors;
return RedirectToAction("Summary");

The Summary controller action, where all this begins, looks for any errors in the tempdata and adds them to the modelstate.

[HttpGet]
[OutputCache(Duration = 0)]
public ActionResult Summary()
{
    // setup, including retrieval of the viewmodel from TempData...


    // And finally if we are coming back to this after a failed attempt to edit some of the fields on the page,
    // load the errors stored from TempData.
        List<string> editErrors = new List<string>();
        object errData = TempData["SummaryEditedErrors"];
        if (errData != null)
        {
            editErrors = (List<string>)errData;
            foreach(string err in editErrors)
            {
                // ValidationSummary() will see these
                ModelState.AddModelError("", err);
            }
        }
Recline answered 10/12, 2015 at 17:22 Comment(0)
A
1

I am giving just sample code here In your viewModel you can add one property of type "ModelStateDictionary" as

public ModelStateDictionary ModelStateErrors { get; set; }

and in your POST action menthod you can write code directly like

model.ModelStateErrors = ModelState; 

and then assign this model to Tempdata like below

TempData["Model"] = model;

and when you redirect to other controller's action method then in controller you have to read the Tempdata value

if (TempData["Model"] != null)
{
    viewModel = TempData["Model"] as ViewModel; //Your viewmodel class Type
    if(viewModel.ModelStateErrors != null && viewModel.ModelStateErrors.Count>0)
    {
        this.ViewData.ModelState.Merge(viewModel.ModelStateErrors);
    }
}

That's it. You don't have to write action filters for this. This is as simple as above code if you want to get Model state errors to another view of another controller.

Amphibolous answered 31/8, 2020 at 14:18 Comment(0)
V
1
    public class RestoreModelStateFromTempDataAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            var controller = filterContext.Controller as Controller;
            if (controller.TempData.ContainsKey("ModelState"))
            {
                var modelState = ModelStateHelpers.DeserialiseModelState(controller.TempData["ModelState"].ToString());
                controller.ViewData.ModelState.Merge(modelState);
            }
            base.OnActionExecuting(filterContext);
        }
    }
    public class SetTempDataModelStateAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuted(ActionExecutedContext filterContext)
        {
            var controller = filterContext.Controller as Controller;
            controller.TempData["ModelState"] = ModelStateHelpers.SerialiseModelState(controller.ViewData.ModelState);
            base.OnActionExecuted(filterContext);
        }
    }

When I resolve some problem, I ran into a lot of non-obvious obstacles. I will indicate everything step by step. My comments will partially duplicate the answers from the current branch

  1. Implement two attributes. You must explicitly specify the type for controller (filterContext.Controller as Controller) because default is object type.
  2. Explicitly implement serialization ModelState from this article https://andrewlock.net/post-redirect-get-using-tempdata-in-asp-net-core/
  3. If TempData is empty in destination action when check implementation cache in startup.cs. You need add memoryCache or SqlServerCache or another https://mcmap.net/q/205319/-passing-tempdata-with-redirecttoaction
Vulgarize answered 29/7, 2021 at 21:50 Comment(0)
L
0

I prefer to add a method to my ViewModel which populates the default values:

public class RegisterViewModel
{
    public string FirstName { get; set; }
    public IList<Gender> Genders { get; set; }
    //Some other properties here ....
    //...
    //...

    ViewModelType PopulateDefaultViewData()
    {
        this.FirstName = "No body";
        this.Genders = new List<Gender>()
        {
            Gender.Male,
            Gender.Female
        };

        //Maybe other assinments here for other properties...
    }
}

Then I call it when ever I need the original data like this:

    [HttpGet]
    public async Task<IActionResult> Register()
    {
        var vm = new RegisterViewModel().PopulateDefaultViewValues();
        return View(vm);
    }

    [HttpPost]
    public async Task<IActionResult> Register(RegisterViewModel vm)
    {
        if (!ModelState.IsValid)
        {
            return View(vm.PopulateDefaultViewValues());
        }

        var user = await userService.RegisterAsync(
            email: vm.Email,
            password: vm.Password,
            firstName: vm.FirstName,
            lastName: vm.LastName,
            gender: vm.Gender,
            birthdate: vm.Birthdate);

        return Json("Registered successfully!");
    }
Linchpin answered 23/12, 2017 at 0:13 Comment(0)
K
0

I make this attribute

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;

namespace Network.Utilites
{
    public class PreserveModelStateAttribute : ActionFilterAttribute
    {
        private const string KeyListKey = "__preserveModelState_keys";

        public override void OnActionExecuted(ActionExecutedContext filterContext)
        {
            var controller = filterContext.Controller as Controller;
            if (controller != null && !controller.ViewData.ModelState.IsValid)
            {
                var keys = controller.ViewData.ModelState.Keys.ToList();
                controller.TempData[KeyListKey] = keys;
                controller.TempData[ModelStateDictionaryTempDataKey()] = controller.ViewData.ModelState;
            }
        }

        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            var controller = filterContext.Controller as Controller;
            if (controller != null && controller.TempData.ContainsKey(KeyListKey))
            {
                var keys = (IEnumerable<string>)controller.TempData[KeyListKey];
                var tempDataModelState = (ModelStateDictionary)controller.TempData[ModelStateDictionaryTempDataKey()];
                foreach (var key in keys)
                {
                    if (!controller.ViewData.ModelState.ContainsKey(key))
                    {
                        controller.ViewData.ModelState.Add(key, tempDataModelState[key]);
                    }
                    else
                    {
                        foreach (var error in tempDataModelState[key].Errors)
                        {
                            controller.ViewData.ModelState[key].Errors.Add(error);
                        }
                    }
                }
            }
        }

        private static string ModelStateDictionaryTempDataKey()
        {
            return "__preserveModelState_modelState";
        }
    }
}

but you use this attrubute for both actions [PreserveModelState]

Kalila answered 20/2, 2023 at 6:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.