A collection of practical Windows Forms habits that reduce flicker, prevent subtle bugs, and keep your UI responsive. Each tip is small on its own but the cumulative effect on a form with real data and real users is significant.
When you assign ValueMember or DisplayMember programmatically, most bound controls immediately query their DataSource and re-populate. If you set those properties after the DataSource is already assigned, the control populates once for each property assignment. With a large source that is visible as a flicker; with a slow source it is a measurable delay.
The fix is to assign DataSource last, so the control populates exactly once with both members already configured:
comboBox1.ValueMember = "Name";
comboBox1.DisplayMember = "Name";
comboBox1.DataSource = mySource;
ListBox and any other control that implements DataSource/DisplayMember/ValueMember. Make it a habit to always put DataSource on the last line of any binding block.
Controls like ListView and TreeView repaint after every item added. Adding 100 items in a loop triggers 100 repaints. BeginUpdate suspends all repainting until EndUpdate is called, so the visual update happens once regardless of how many items were added.
listView1.BeginUpdate();
for (int i = 0; i < 100; i++)
{
ListViewItem listItem = new ListViewItem("Item" + i.ToString());
listView1.Items.Add(listItem);
}
listView1.EndUpdate();
try/finally block and call EndUpdate in the finally clause. A control stuck in update-suspended state will stop repainting entirely until the next call to EndUpdate.
When you add or reposition multiple controls on a form or panel at runtime, the layout engine recalculates and redraws after each change. SuspendLayout and ResumeLayout are the container-level equivalent of BeginUpdate: they defer all layout recalculation until you call ResumeLayout.
panel1.SuspendLayout();
for (int i = 0; i < 20; i++)
{
var lbl = new Label
{
Text = "Row " + i,
Location = new System.Drawing.Point(0, i * 24),
Width = 200
};
panel1.Controls.Add(lbl);
}
panel1.ResumeLayout(performLayout: true);
The true argument to ResumeLayout triggers a single layout pass immediately after resuming. Pass false if you intend to call PerformLayout yourself at a later point.
InitializeComponent body in SuspendLayout and ResumeLayout for exactly this reason. Apply the same pattern in any code that adds or moves more than one control at a time.
When a control does custom painting via OnPaint, Windows first erases the background and then draws the new content. That two-step process causes a visible flicker, particularly on resize or fast refresh. Double buffering renders the entire frame off-screen first, then blits it to the screen in one step.
public class FlickerFreePanel : Panel
{
public FlickerFreePanel()
{
SetStyle(
ControlStyles.OptimizedDoubleBuffer |
ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint,
value: true);
UpdateStyles();
}
}
AllPaintingInWmPaint suppresses the separate WM_ERASEBKGND message so the background and foreground are painted together in a single WM_PAINT pass. Without it, OptimizedDoubleBuffer alone does not fully eliminate the erase flicker.
typeof(Control).GetProperty("DoubleBuffered", BindingFlags.NonPublic | BindingFlags.Instance)?.SetValue(listView1, true);. It works but subclassing is the cleaner long-term approach.
Application.DoEvents was the old way to keep a form responsive during a long-running operation on the UI thread. It processes all pending Windows messages inline, which can cause re-entrant event handlers, corrupted state, and unpredictable behaviour. It is effectively a controlled hazard.
The correct replacement is to move the work off the UI thread with async/await:
// Avoid
private void btnProcess_Click(object sender, EventArgs e)
{
for (int i = 0; i < 10000; i++)
{
DoHeavyWork(i);
Application.DoEvents(); // keeps UI alive but risks re-entrancy
}
}
// Prefer
private async void btnProcess_Click(object sender, EventArgs e)
{
btnProcess.Enabled = false;
await Task.Run(() =>
{
for (int i = 0; i < 10000; i++)
DoHeavyWork(i);
});
btnProcess.Enabled = true;
}
Disabling the button during the operation is important. With the async approach the UI thread is free, so the user can click the button again before the first run finishes if you leave it enabled.
Controls added at runtime are not automatically disposed when removed from their parent container. Calling Controls.Remove detaches the control from the visual tree but leaves its handle, event subscriptions, and any unmanaged resources alive. Over time, especially in forms that rebuild their content repeatedly, this adds up to a genuine memory leak.
// Avoid
panel1.Controls.Remove(myControl);
// Prefer
panel1.Controls.Remove(myControl);
myControl.Dispose();
If you are clearing an entire container, iterate over a snapshot of the controls collection rather than the live one, since disposing a control also removes it from its parent:
foreach (Control ctrl in panel1.Controls.Cast<Control>().ToList())
{
panel1.Controls.Remove(ctrl);
ctrl.Dispose();
}
Showing a MessageBox for every validation failure interrupts the user's flow and forces a modal dismissal before they can correct anything. ErrorProvider displays a small icon next to the offending control and shows a tooltip on hover, keeping the form fully interactive throughout.
private readonly ErrorProvider _errorProvider = new ErrorProvider();
private void txtAge_Validating(object sender, CancelEventArgs e)
{
if (!int.TryParse(txtAge.Text, out int age) || age < 0 || age > 150)
{
_errorProvider.SetError(txtAge, "Please enter a valid age between 0 and 150.");
e.Cancel = true;
}
else
{
_errorProvider.SetError(txtAge, string.Empty);
}
}
Wire the validation to the control's Validating event rather than a button click. Windows Forms triggers Validating when focus leaves the control, so the user gets immediate feedback without waiting to submit the whole form.
_errorProvider.Clear() on successful form submission to ensure no stale error icons remain visible after the data has been saved.
Every call to Controls.Add or Items.Add can trigger internal bookkeeping and, depending on the control, a layout or repaint pass. When you have a fixed set of items or controls to add at once, AddRange does the same work in a single call and gives the control a chance to batch its internal updates.
// Avoid
comboBox1.Items.Add("Apple");
comboBox1.Items.Add("Banana");
comboBox1.Items.Add("Cherry");
// Prefer
comboBox1.Items.AddRange(new object[] { "Apple", "Banana", "Cherry" });
The same applies to adding controls to a container:
panel1.Controls.AddRange(new Control[] { label1, textBox1, btnSave });
Combining AddRange with SuspendLayout and ResumeLayout (Tip 3) gives you the best of both: a single collection operation inside a deferred layout pass.
A common mistake when using Task.Run for background work is attempting to update a progress bar or status label directly from inside the lambda. Controls can only be safely accessed from the UI thread, and touching them from a background thread causes either a cross-thread exception or silent corruption.
IProgress<T> solves this cleanly. Construct a Progress<T> on the UI thread and pass it into the background work. Calls to Report are automatically marshalled back to the UI thread via the captured SynchronizationContext:
private async void btnRun_Click(object sender, EventArgs e)
{
btnRun.Enabled = false;
progressBar1.Value = 0;
var progress = new Progress<int>(percent =>
{
progressBar1.Value = percent;
lblStatus.Text = $"Processing... {percent}%";
});
await Task.Run(() =>
{
for (int i = 1; i <= 100; i++)
{
DoHeavyWork(i);
((IProgress<int>)progress).Report(i);
}
});
lblStatus.Text = "Done.";
btnRun.Enabled = true;
}
Progress<T> coalesces rapid Report calls on the UI thread's message queue, so you will not flood the UI if the background work reports progress faster than the UI can process it.
On high-DPI displays, forms with AutoScaleMode set to Font (the designer default) scale their controls relative to the current system font size, which works well across different DPI settings. The problems start when AutoScaleMode is accidentally set to None or when child panels have a different AutoScaleMode from their parent, causing misaligned controls at non-standard DPI settings.
// In your Form constructor, after InitializeComponent
this.AutoScaleMode = AutoScaleMode.Dpi;
AutoScaleMode.Dpi scales based on the screen's DPI value directly rather than the font metric, which gives more predictable results on modern displays. Pair it with a DPI-aware application manifest so Windows does not virtualise the process:
// Program.cs — call before Application.Run
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
PerMonitorV2 is the most capable mode: the form re-scales itself each time it moves to a monitor with a different DPI, which matters on multi-monitor setups where screens run at different resolutions.
Application.SetHighDpiMode is available from .NET 5 onwards. For .NET Framework projects, the equivalent is a dpiAware and dpiAwareness entry in the application manifest file.
No comments: