2008-01-16

ASP.Net, Dynamic Controls, and ViewState (oh my)

Let's say you have an ASP.Net page. On this page is a PlaceHolder. In that page, controls may get dynamically loaded into the PlaceHolder. This may occur as a result of a postback event (e.g. Button or LinkButton click) or simply as a result of some logic that occurs when the page loads. However, even with EnableViewState set on the PlaceHolder, controls that you load are not automatically persisted in ViewState. What to do?

This is a problem I've run into at least a half dozen times in my .Net coding career. It's also a problem I've solved that many times. However, every time I come across it, I forget one small detail, or I do something a little differently that makes it all fall apart. So, at least for my own benefit and perhaps for the benefit of others that may stumble upon this post, I'm putting the solution here. I haven't yet seen anything that pulls all these techniques together; this might not be the first, but at least it's one I know. Links to other pages contained here may not be from where I first got my information, but I'm including them for further reference and information.

First: When you create your controls, make sure you add them to the page FIRST, and THEN set any properties on them. This is because, in order for the ViewState manager to consider the control for management, it has to detect a change, and it can only detect changes after the control has been added to the Controls collection. (Thanks to Jeffrey Palermo's blog for pointing this out, and explaining the reason for it.) But what if you don't have any properties to set? That is remedied by the next consideration.

Second: Set an explicit ID. When ViewState is saved and restored, the control IDs must match. (I've seen a reference that says you have to use a custom class and explicitly tag it "ViewStateModeById", but my experience has shown me that ID matters whether you do this or not. Sequence may or may not be as important.) If you don't specify an ID, ASP.Net will automatically generate one, and there is no guarantee that the one generated when you create it on the fly will be the same when you recreate it on PostBack. The result is, you may get the control back, but its state will not persist on the first PostBack (although it might on the second and subsequent PostBacks, since it will be consistently reloading during the PostBack from the same spot -- this behavior may be confusing).

The tricky part comes in play when you have to reload the controls on PostBack. You have to do it before ViewState gets loaded, otherwise the controls won't be there to get their ViewState restored to them. The event before LoadViewState is Init. But you have to somehow have the page remember what controls were loaded. Keeping that value in ViewState is the most convenient, but you can't retrieve that value from Init because ViewState hasn't loaded yet. What to do?

I've seen one solution to this as to use the Session. I'm not a big fan of this myself for a few reasons. First, it requires Session. I consider it a goal to write web applications that don't require Session at all, because they generally perform faster and are more scalable. Granted, I rarely succeed, and Session does have its place, so writing it off isn't a valid enough reason. Second, it is very easy to get the page state out of sync with the session state. Hit the "Back" button a couple times, and then submit. The page submits a ViewState with two controls, but the server checks the Session variable and loads four controls, and then tries to apply the ViewState to it. Third, that variable has the lifetime of the Session. At worst, if it's not cleared out or checked appropriately, its presence may cause confusion on another page. At best, it's just taking up a couple bytes of memory long after its purpose has been served.

So what's the solution then? Third: My trick is to override the SaveViewState and LoadViewState methods on the page. SaveViewState returns a serializable object that gets dumped to the page. It's fairly trivial to override this method to return an object[2] (which is still an object and still serializable), putting your own data in one element and the results of base.SaveViewState() in the other. What you need to store in that element is something you can use to recreate the controls in the order they exist on the page. Maybe it's just a number -- if you create the controls as a series and give them IDs of ID{x}, where {x} is the sequential number, then all you need is the number of controls to recreate. Or maybe it's a string array that contains the list of control IDs. Or perhaps, if the controls are of different types, it's an array of the control types that have to be loaded, or the paths to the .ascx files. Whatever it takes.

The LoadViewState method is overridden to check to see if the ViewState object is an object array, and if the first element is of the type we expect (the one we create in SaveViewState). If so, it recreates the controls based on that first value, and then calls base.LoadViewState on the second. (If it turns out not to be an object array or something is not of the expected type, I am in the habit of just calling base.LoadViewState directly, just as a safety catch in case the overridden SaveViewState got missed. It's never happened yet, though.)

The code, therefore, looks a little like this:

/// <summary>
/// Saves the view state, including the IDs of placeholder controls so they can be reloaded on postback
/// </summary>
/// <returns>new ViewState object</returns>
protected override object SaveViewState() {
    object[] newViewState = new object[2];

    List<string> docTypeSelectionControlIDs = new List<string>();
    foreach (Control control in placeHolder1.Controls) {
        if (control is DocTypeSelectionControl) docTypeSelectionControlIDs.Add(control.ID);
    }
    string[] arrayOfIDs = docTypeSelectionControlIDs.ToArray();

    newViewState[0] = arrayOfIDs;
    newViewState[1] = base.SaveViewState();

    return newViewState;
}

/// <summary>
/// Loads the view state, including custom state information from the SaveViewState override
/// </summary>
/// <param name="savedState"></param>
protected override void LoadViewState(object savedState) {
    //if we can identify the custom view state as defined in the override for SaveViewState
    if (savedState is object[] && ((object[])savedState).Length == 2 && ((object[])savedState)[0] is string[]) {
        //re-load the DocTypeSelectionControls into the placeholder and restore their IDs
        object[] newViewState = (object[])savedState;
        string[] arrayOfDocTypeSelectionControlIDs = (string[])(newViewState[0]);
        foreach (string docTypeSelectionControlID in arrayOfDocTypeSelectionControlIDs) {
            DocTypeSelectionControl dtsc = (DocTypeSelectionControl) LoadControl("DocTypeSelectionControl.ascx");
            placeHolder1.Controls.Add(dtsc);
            dtsc.ID = docTypeSelectionControlID;
        }
        //load the ViewState normally
        base.LoadViewState(newViewState[1]);
    } else {
        base.LoadViewState(savedState);
    }
}

/// <summary>
/// Loads the page, including some dynamic controls
/// </summary>
protected override void OnLoad(EventArgs e) {
    if (!IsPostBack) {
        Document[] documents = (Document[])(Session["Documents"]); //The size of this array varies
        foreach (Document doc in documents) {
            DocTypeSelectionControl dtsc = (DocTypeSelectionControl) LoadControl("DocTypeSelectionControl.ascx");
            placeHolder1.Controls.Add(dtsc); //Add control FIRST
            dtsc.ID = String.Format("DTSC_{0}", doc.DocumentID); //THEN set ID
            dtsc.InitializeControl(doc); //control initializes itself based on this document, and saves info in its ViewState
        }
    }
}

Note that this is just a quick sample. The controls in the placeholder could be modified as a result of some control event, but it really doesn't matter. The part that controls saving and reloading the ViewState of the controls doesn't change.

UPDATE 4 Dec 2009 — I created a sample project, using Visual Studio 2005 and .Net 2.0, that probably does a better job at describing things than I'm trying to do here. The project is very simple, with a single page and a single control. The page starts by loading a single instance of the control into a placeholder, and a button lets you add as many more instances of the control as you want. Every time you add a control, there is a PostBack, and all existing controls get their state and values loaded and re-saved to ViewState. You can see that the controls themselves do nothing to save their values from PostBack to PostBack; it all happens automatically thanks to the standard ViewState manager. When you click the "Submit" button, the main page loops through all the controls, collects the selected values, and builds a table to display the results. There is no use of Session, and at any time you can use the Back (or even Forward) buttons to navigate to a previous state of the page and pick up from that point.

DynamicControls.zip

11 comments:

Sky Strider said...

This works to maintain the same controls on the page over the postbacks, however the whole point of viewstate is to maintain the user's input within the controls that were dynamically added. This is not being accomplished. How can the application retrieve the values the user places within the dynamically added web controls?

Yakko Warner said...

"This is not being accomplished."

Yes it is. That's the whole point of loading the controls before calling base.LoadViewState(). ASP.Net's own ViewState management process takes care of loading/saving the state of the dynamic controls, just like any other static control. You just have to set the conditions just right for the standard ViewState manager to work with the dynamic controls — ASP.Net does the rest, automatically.

"How can the application retrieve the values the user places within the dynamically added web controls?"

Easy. Once the steps are followed as I've outlined, then the controls get populated after LoadViewState, just like you'd expect.

It does work. I've done it just this way in both .Net 1.1 and 2.0

Mike said...

Yakko Warner is my new hero! I have been struggling with this off and on for a few years. I usually ended up hardcoding the number of controls and hiding those not currently active but that was a waste.


Worked perfectly in .Net 3.5.

Andy said...

Thank you! Adding the control to the page then setting the properties was the fix. I tried just about everything but that. I was about to go a new direction. You da man.

Eric said...

Yakko,

This was just what I needed to get over the hump of a project I'm working on... It's a novel solution and worked great! Thanks for this!

Rafael Ávila said...

Yakko, you are a genius!!! This solution works perfect well.

Thanks a lot!!!

Unknown said...

Hello, Yakko!

Thanks a lot for this example - the first thing that worked for me after a big struggle with dynamic controls!

However, i had to save and load not only the ids but also the values of my dynamic controls, in order for my application to work.

So i have 2 questions:

1. Can you show the code that is in "DocTypeSelectionControl.ascx"? I suppose that my error comes from my function for dynamic creation of controls and setting their values. (I followed the rule: When you create your controls, make sure you add them to the page FIRST, and THEN set any properties on them.)

2. Also can you also show the contents of "documents"?

Sorry if the questions are stuped, but i am a real newbe in .NET and C#!

Thanks in advance!

Yakko Warner said...

Unfortunately, I don't have access to the code anymore. I was laid off from that job long ago. In fact, I haven't been working in ASP.Net in a long while. But let's see what I can remember.

The idea, in this example, is that the code in OnLoad isn't the important part here. I only included it to show that the page is initially populated (note the !IsPostBack) with some controls, and how that might be done. It's actually a pretty bad example, because I don't like using Session when I can avoid it. But it was simpler than including a whole lot of code that showed the postback handlers for buttons that added new controls and stuff.

As I recall, the DocTypeSelectionControl.ascx was a very simple control. I think it just had an asp:TextBox that contained a document's filename, and an asp:DropDownList that had a list of document types (like "Tax Document", "Pay Stub", etc.).

The Document object (the collection of which is the "documents" variable, retrieved out of Session on the page's first load in this example) just had those two elements as properties (.Filename and .DocType), and the DocTypeSelectionControl.InitializeControl(Document) method just sets the value of the textbox and dropdown list to the .Filename and .DocType properties. There may have been an additional ID whose value was set to an asp:HiddenField control, I dunno.

The .ascx doesn't do anything special in its Initialize or Load events. You don't want to put any initializing code in its Load event, because that will undo anything the ViewState manager happens to restore. You can't even put it in there wrapped in a !IsPostBack event, because when you're adding a new control, you're doing it on postback, so a new control would never get initialized.

You shouldn't have to be storing the values of the controls in ViewState manually.

I whipped up an example project that should explain it a whole lot better. I used Visual Studio 2005 and .Net 2.0. You can download it here. The default page loads one instance of a dynamic control on its first load, and you can press a button to add as many additional controls as you wish. Each time you add a new control, there is a postback event where all existing controls' states are saved and loaded from ViewState. You can see that the controls themselves do nothing to keep their own values.

lnx4tw said...

I really appreciate the post! I was tearing my hair out trying to get this to work. This is ingenious!

Unknown said...

THANK YOU SO MUCH, YAKKO !!!

As i supposed, my error was that i initialized the dynamic controls in LoadViewState(). Now that i tested your example, everything is working all right in my project too!

Your example helped me not only to do my task, but also to realize the sequence of events in a web application and how to work with a user control!

THANKS A LOT!

positron said...

I Love you baby !!! its work lyk charm... you saved my time God!