In this post I will show how you can use Session state and TempData to implement the POST-REDIRECT-GET (PRG) design pattern in your ASP.NET Core application.
Disclaimer - The technique shown here, while working very well in the previous version of ASP.NET, is not as simple in ASP.NET Core. This is due to the fact that the
TempData
object is a wrapper around Session which is itself a wrapper around theIDistributedCache
interface. This interface requires you to serialise your objects to and from a byte array before storage, where previously serialisation was not necessary. Consequently there are some trade-offs required in this implementation, so be sure you understand the implications.
What is PRG?
The POST-REDIRECT-GET (PRG) design pattern states that a POST should be answered with a REDIRECT response, to which the user's browser will follow with a GET request. It is designed to reduce the number of duplicate form submissions caused by users refreshing their browser and navigating back and forth.
No doubt in your general internet travels you will have refreshed a page and seen a popup similar to the following:
This occurs when the response returned from a POST is just content, with no REDIRECT. When you click reload, the browser attempts to resend the last request, which in this case was a POST. In some cases this may be the desired behaviour, but in my experience it invariably is not!
Luckily, as suggested, handling this case is simple when the form data submitted in the post is valid and can be handled correctly. Simply return a redirect response from your controller actions to a new page. So for example, consider we have a simple form on our home page which can POST an EditModel
. If the form is valid, then we redirect to the Success
action, instead of returning a View
result directly. That way if the user reloads the screen, they replay the GET request to Success
instead of the POST
to Index
.
public class HomeController : Controller
{
public IActionResult Index()
{
return View(new EditModel());
}
[HttpPost]
public IActionResult Index(EditModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
return RedirectToAction("Success");
}
public IActionResult Success()
{
return View();
}
}
Handling invalid forms
Unfortunately the waters get a little more muddy when the form data you have submitted is not valid. As PRG is primarily intended to prevent double form submissions, it does not necessarily follow that you should REDIRECT a user if the form is invalid. In that case, the request should not be modifying state, and so it is valid to submit the form again.
In MVC, this has generally been the standard way of handling invalid forms. In the example above, we check the ModelState.IsValid
property in our POST handler, and if not valid, we simply redisplay the form, using the current ModelState
to populate the validation helpers etc. This is a conceptually simple solution, that still allows us to use PRG when the post is successful.
Unfortunately, this approach has some drawbacks. It is still quite possible for users to be hit with the (for some, no-doubt confusing) 'Confirm form resubmission' popup.
Consider the controller above. A user can submit the form, where if invalid we return the validation errors on the page. The user then reloads the page and is shown the 'Confirm form resubmission' popup:
It is likely the user expected reloading the page to actually reload the page and clear the previously entered values, rather than resubmitting the form. Luckily, we can use PRG to produce that behaviour and to provide a cleaner user experience.
Using TempData to save ModelState
The simple answer may seem to be changing the View(model)
statement to be RedirectToAction("Index")
- that would satisfy the PRG requirement and prevent form resubmissions. However doing that would cause a 'fresh' GET on the Index page, so we would lose the previously entered input fields and all of the validation errors - not a nice user experience at all!
In order to display the validation messages and input values we need to somehow preserve the ModelStateDictionary
exposed as ModelState
in the controller. In ASP.NET 4.X, that is relatively easy to do using the TempData
structure, which stores data in the Session for the current request and the next one, after which it is deleted.
Matthew Jones has an excellent post on using TempData
to store and rehydrate the ModelState
when doing PRG in ASP.NET 4.X, which was the inspiration for this post. Unfortunately there are some limitations in ASP.NET Core which make the application of his example slightly less powerful, but hopefully still sufficient in the majority of cases.
Serialising ModelState to TempData
The biggest problem here is that ModelState
is not generally serialisable. As discussed in this GitHub issue, ModelState can contain Exception
s which themselves may not be serialisable. This was not an issue in ASP.NET 4.X as TempData would just store the ModelState
object itself, rather than having to serialise at all.
To get around this, we have to extract the details we care about from the ModelStateDictionary
, serialise those details, and then rebuild the ModelStateDictionary
from the serialised representation on the next request.
To do this, we can create a simple serialisable transport class, which contains only the details we need to redisplay the form inputs correctly:
public class ModelStateTransferValue
{
public string Key { get; set; }
public string AttemptedValue { get; set; }
public object RawValue { get; set; }
public ICollection<string> ErrorMessages { get; set; } = new List<string>();
}
All we store is the Key
(the field name) the RawValue
and AttemptedValue
(the field values) and the ErrorMessages
associated with the field. These map directly to the equivalent fields in ModelStateDictionary
.
Note that the
RawValue
type is anobject
, which again leaves us with the problem thatModelStateTransferValue
may not be serialisable. I haven't come across any times where this is the case but it is something to be aware of if you are using some complex objects in your view models.
We then create a helper class to allow us to serialise the ModelSateDictionary
to and from TempData
. When serialising, we first convert it to a collection of ModelStateTransferValue
and then serialise these to a string. On deserialisation, we simply perform the process in reverse:
public static class ModelStateHelpers
{
public static string SerialiseModelState(ModelStateDictionary modelState)
{
var errorList = modelState
.Select(kvp => new ModelStateTransferValue
{
Key = kvp.Key,
AttemptedValue = kvp.Value.AttemptedValue,
RawValue = kvp.Value.RawValue,
ErrorMessages = kvp.Value.Errors.Select(err => err.ErrorMessage).ToList(),
});
return JsonConvert.SerializeObject(errorList);
}
public static ModelStateDictionary DeserialiseModelState(string serialisedErrorList)
{
var errorList = JsonConvert.DeserializeObject<List<ModelStateTransferValue>>(serialisedErrorList);
var modelState = new ModelStateDictionary();
foreach (var item in errorList)
{
modelState.SetModelValue(item.Key, item.RawValue, item.AttemptedValue);
foreach (var error in item.ErrorMessages)
{
modelState.AddModelError(item.Key, error);
}
}
return modelState;
}
}
ActionFilters for exporting and importing
With these helpers in place, we can now create the ActionFilters
where we will store and rehydrate the model data. These filters are almost identical to the ones proposed by Matthew Jones in his post, just updated to ASP.NET Core constructs, and calling our ModelStateHelpers
as required:
public abstract class ModelStateTransfer : ActionFilterAttribute
{
protected const string Key = nameof(ModelStateTransfer);
}
public class ExportModelStateAttribute : ModelStateTransfer
{
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
//Only export when ModelState is not valid
if (!filterContext.ModelState.IsValid)
{
//Export if we are redirecting
if (filterContext.Result is RedirectResult
|| filterContext.Result is RedirectToRouteResult
|| filterContext.Result is RedirectToActionResult)
{
var controller = filterContext.Controller as Controller;
if (controller != null && filterContext.ModelState != null)
{
var modelState = ModelStateHelpers.SerialiseModelState(filterContext.ModelState);
controller.TempData[Key] = modelState;
}
}
}
base.OnActionExecuted(filterContext);
}
}
public class ImportModelStateAttribute : ModelStateTransfer
{
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
var controller = filterContext.Controller as Controller;
var serialisedModelState = controller?.TempData[Key] as string;
if (serialisedModelState != null)
{
//Only Import if we are viewing
if (filterContext.Result is ViewResult)
{
var modelState = ModelStateHelpers.DeserialiseModelState(serialisedModelState);
filterContext.ModelState.Merge(modelState);
}
else
{
//Otherwise remove it.
controller.TempData.Remove(Key);
}
}
base.OnActionExecuted(filterContext);
}
}
The ExportModelStateAttribute
runs after an Action has executed, checks whether the ModelState
was invalid and if the returned result was a redirect result. If it was, then it serialises the ModelState
and stores it in TempData
.
The ImportModelStateAttribute
also runs after an Action has executed, checks we have a serialised model state and that we are going to execute a ViewResult
. If so, then it deserialises to state to a ModelStateDictionary
and merges it into the existing ModelState
.
We can simply apply these attributes to our HomeController
to give PRG on invalid forms, if we also update the !ModelState.IsValid
case to redirect to Index
:
public class HomeController : Controller
{
[ImportModelState]
public IActionResult Index()
{
return View(new EditModel());
}
[HttpPost]
[ExportModelState]
public IActionResult Index(EditModel model)
{
if (!ModelState.IsValid)
{
return RedirectToAction("Index");
}
return RedirectToAction("Success");
}
}
The result
We're all set to give this a try now. Previously, if we submitted a form with errors, then reloading the page would give us the 'Confirm form resubmission' popup. This was because the POST was being resent to the server, as we can see by viewing the Network tab in Chrome:
See those POSTS returning a 200? That's what we're trying to avoid. With our new approach, errors in the form cause a redirect to the Index
page, followed by a GET request by the browser. The form fields and validation errors are all still visible, even though this is a normal GET request.
Out POST now returns a 302, which is followed by a GET. Now if the user refreshes the page, the page will actually refresh, clearing all the input values and validation errors and giving you a nice clean form, with no confusing popups!
Summary
This post shows how you can implement PRG for all your POSTs in ASP.NET Core. Whether you actually want to have this behaviour is another question which is really up to you. It allows you to avoid the annoying popups, but on the other hand it is not (and likely will not be) a pattern that is directly supported by the ASP.NET Core framework itself. The ModelState
serialisation requirement is a tricky problem which may cause issues for you in some cases, so use it with caution!
To be clear, you absolutely should be using the PRG pattern for successful POSTs, and this approach is completely supported - just return a RedirectResult
from your Action method. The choice of whether to use PRG for invalid POSTs is down to you.