Sunday, June 17, 2012

Persisting model state when using PRG

I've been working on an ASP.NET MVC application in which we frequently apply the Post/Redirect/Get pattern. One of the direct consequences of applying this pattern is that you often want to persist the model state across redirects, so that you don't lose validation errors, or the values of input fields.

To persist the model state across redirects, we can put TempData to work. The sole purpose of TempData is exactly this; persisting state until the next request.
public ActionResult Index()
{
    ViewData.Model = ...

    if (TempData.ContainsKey("ModelState"))
        ModelState.Merge((ModelStateDictionary)TempData["ModelState"]);

    return View();
}

[HttpPost]        
public ActionResult Update(AddModel inputModel)
{
    if (ModelState.IsValid)
        ...

    TempData["ModelState"] = ModelState;

    return RedirectToAction("Index");
}
So this works, but I found it to be a bit too cumbersome. And so did Davy Brion, he introduced a clean abstraction into the project, smoothing out some of the friction: making use of action filter attributes, we were able to eliminate duplication across controllers, leaving behind an AOP-ish taste.

The SetTempDataModelStateAttribute stores the model state in the TempData dictionary.
public class SetTempDataModelStateAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        base.OnActionExecuted(filterContext);         
        filterContext.Controller.TempData["ModelState"] = 
           filterContext.Controller.ViewData.ModelState;
    }
}
While the RestoreModelStateFromTempDataAttribute restores it by pulling it out of TempData again, when it exists.
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"]);
        }
    }
}
So when we apply these attributes to the example at the beginning of this post, we end up with something like this.
[RestoreModelStateFromTempData]
public ActionResult Index()
{
    ViewData.Model = ...    

    return View();
}

[HttpPost]   
[SetTempDataModelState]     
public ActionResult Update(AddModel inputModel)
{
    if (ModelState.IsValid)
        ...
    
    return RedirectToAction("Index");
}
Very clean. I'm interested to hear how you handle these concerns when using the PRG pattern.

11 comments:

  1. Great Article :)

    We can also submit our .net related links on http://www.dotnettechy.com to improve traffic.

    The dotnettechy.com is a community of .Net developers joined together to learn, to teach, to find solutions, to find interview questions and answers, to find .net website / blog collection and to have fun programming.

    ReplyDelete
  2. I use the ModelStateToTempDataAttribute in MvcContrib https://github.com/mvccontrib/MvcContrib/blob/master/src/MVCContrib/Filters/ModelStateToTempDataAttribute.cs (Jeremy Skinner, 2008)

    Works in the exact way.

    ReplyDelete
  3. I use the ModelStateToTempDataAttribute in MvcContrib https://github.com/mvccontrib/MvcContrib/blob/master/src/MVCContrib/Filters/ModelStateToTempDataAttribute.cs (Jeremy Skinner, 2008)

    Works in the same way.

    ReplyDelete
  4. Why not just return the View if the model fails validation.

    ReplyDelete
    Replies
    1. I don't want a request to be resubmitted by accident on a refresh.

      Delete
  5. aherrick is right, you shouldn't be using PRG when you want to persist posted data i.e. remember user values on a form that has been posted. You should return the view.

    The logic goes like this in the controller action (for the post)

    if (ModelState.IsValid){
    tempdata("Confirmation") = New Confirmation("Save OK", "Record saved")
    return RedirectToAction("x", "y")
    } else {
    return View(Model)
    }

    If the request is "accidentally resubmitted" it will just fail validation again. If it pases validation, PRG kicks in and you won't get a double post.

    ReplyDelete
    Replies
    1. The form on the Index view posts to the Update action. If I return a view in my Update action, I need to create another view, or if I want to use the Index view there, I'm messing up my url's.

      Delete
  6. There is a better way - give your GET and POST actions the same name. The GET takes an ID or similar as a parameter, and the POST takes the model.

    Then in your view you can use Html.BeginForm() without any parameters, and it will POST to the correct location.

    It's less work, easier and works well :)

    ReplyDelete
    Replies
    1. I took that approach before, but you still have the double form submission problem when the client refreshes, and it's not very 'correct' url-wise. Also, if you post to multiple actions in one page, it gets messy real fast.

      Delete
  7. You should be careful with this approach. Since you are using the same key "ModelState" across all actions and controllers and since tempdata is not guaranteed to clear after request, your model state can get mixed between actions. If you want to use this approach, i would recommend using TempData[filterContext.ActionDescriptor.ActionName] as the key instead. This will keep your GET and POST together but not mix other actions. This of course assumes you are PRG and keeping your GETs and POSTs actions the same name.

    ReplyDelete
  8. So, after user input values, submit form, got validation errors and then hits F5 he will loose all of his input?

    ReplyDelete