Anybody that's been on the internet for more than five seconds has encountered one of these:
I'm a fan of getting rid of anything that interferes with the user experience, and these dialogs certainly get in the way. There's a pattern we can implement, called POST-REDIRECT-GET or PRG, that will eliminate these dialogs.
Let's see what that pattern is, and how we can implement it in a simple ASP.NET MVC application.
What is PRG?
POST-REDIRECT-GET is a pattern that says a POST action should always REDIRECT to a GET action. This pattern is meant to provide a more intuitive interface for users, specifically by reducing the number of duplicate form submissions.
The Normal Way
Here's the code files we'll use for a regular POST scenario. First, we will build a view model class AddUserVM
:
public class AddUserVM
{
[DisplayName("First Name:")]
[Required(ErrorMessage = "Please enter a first name.")]
public string FirstName { get; set; }
[DisplayName("Last Name:")]
[Required(ErrorMessage = "Please enter a last name.")]
public string LastName { get; set; }
[DisplayName("Date of Birth:")]
[DataType(DataType.Date)]
[DisplayFormat(DataFormatString = "{0:yyyy-mm-dd}", ApplyFormatInEditMode = true)]
[Required(ErrorMessage = "Please select a date of birth.")]
public DateTime DateOfBirth { get; set; }
}
We will also need a HomeController
controller with a Normal()
action:
public class HomeController : Controller
{
[HttpGet]
public ActionResult Normal()
{
AddUserVM model = new AddUserVM();
return View(model);
}
[HttpPost]
public ActionResult Normal(AddUserVM model)
{
if(!ModelState.IsValid)
{
return View(model);
}
return RedirectToAction("Index");
}
}
Finally, we need the Home/Normal.cshtml view:
@model StrictPRGDemo.ViewModels.Home.AddUserVM
<h2>Add a User (Normal)</h2>
@using(Html.BeginForm())
{
<div>
<div>
@Html.LabelFor(x => x.FirstName)
@Html.TextBoxFor(x => x.FirstName)
@Html.ValidationMessageFor(x => x.FirstName)
</div>
<div>
@Html.LabelFor(x => x.LastName)
@Html.TextBoxFor(x => x.LastName)
@Html.ValidationMessageFor(x => x.LastName)
</div>
<div>
@Html.LabelFor(x => x.DateOfBirth)
@Html.EditorFor(x => x.DateOfBirth)
@Html.ValidationMessageFor(x => x.DateOfBirth)
</div>
<div>
<input type="submit" value="Save" />
</div>
</div>
}
With each of these components implemented, when we run our sample app the rendered page looks like this:
If we immediately click Save, our validation fires:
But what happens if we refresh the page? We get the validation warning:
Let's get rid of that warning dialog using PRG.
The PRG Way
PRG says that all POSTS need to redirect to a GET action, which sounds easy enough. But if we try this in a naive solution, where instead of returning the View(model) when validation fails we just redirect back to the GET action, the validation messages and input values will not appear.
Here's the problem: those validation messages and input values are stored in the ModelState
, which gets recreated when moving between actions.
We need a way to save the model state somewhere that we can access it later.
Lucky for us, there's a data structure called TempData. TempData allows data to exist for the current request and the next one, and then the data gets deleted. Sounds like that'll fill our needs, don't it?
In fact, six years ago, Kazi Mansur Rashid wrote a blog post that laid out exactly how we can use TempData to store the ModelState, and even better, how we can wrap that action in a set of attributes.
PRG Attributes
The first attribute we need is called ModelStateTransfer
. This is a base class that our export and import attributes will inherit from.
public abstract class ModelStateTransfer : ActionFilterAttribute
{
protected static readonly string Key = typeof(ModelStateTransfer).FullName;
}
Next is the ExportModelStateAttribute
, which will be used on POST actions to export the current ModelState
to TempData
.
public class ExportModelStateAttribute : ModelStateTransfer
{
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
//Only export when ModelState is not valid
if (!filterContext.Controller.ViewData.ModelState.IsValid)
{
//Export if we are redirecting
if ((filterContext.Result is RedirectResult) || (filterContext.Result is RedirectToRouteResult))
{
filterContext.Controller.TempData[Key] = filterContext.Controller.ViewData.ModelState;
}
}
base.OnActionExecuted(filterContext);
}
}
Finally, we have the ImportModelStateAttribute
class, which will decorate GET actions where we want to import the ModelState
from TempData
.
public class ImportModelStateAttribute : ModelStateTransfer
{
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
ModelStateDictionary modelState = filterContext.Controller.TempData[Key] as ModelStateDictionary;
if (modelState != null)
{
//Only Import if we are viewing
if (filterContext.Result is ViewResult)
{
filterContext.Controller.ViewData.ModelState.Merge(modelState);
}
else
{
//Otherwise remove it.
filterContext.Controller.TempData.Remove(Key);
}
}
base.OnActionExecuted(filterContext);
}
}
We can use these attributes on our HomeController
class like so:
[HttpGet]
[ImportModelState]
public ActionResult Strict()
{
AddUserVM model = new AddUserVM();
return View(model);
}
[HttpPost]
[ExportModelState]
public ActionResult Strict(AddUserVM model)
{
if (!ModelState.IsValid)
{
return RedirectToAction("Strict");
}
return RedirectToAction("Index");
}
Know what's even better? Our view model and view don't have to change at all!
Now, let's imagine we're back at this point:
If we refreshed the page, in the normal scenario we would have gotten the duplicate submission warning, but in our PRG scenario, we get this:
Look at that! We don't get an error, and the page actually refreshed itself rather than trying to resubmit the values. Best of all, the user's experience is that the page did exactly what the user told it to do. It's a little more work than the normal way, but IMO it makes it just that much nicer for our users.
It's just that easy! Check out the sample project on GitHub and let me know what you think of this technique in the comments.
Happy Coding!