1. Introduction
In many modern C# projects there are almost no "regular" methods for handling events. That's because lambda expressions let you quickly and succinctly declare a handler right at the subscription site (the += operator) if the handling is simple and not reused elsewhere. It's like sticking a small note directly on the coffee machine that says "press this button" instead of writing a detailed manual and storing it in a separate folder. If the task is local and one-off, a lambda is perfect!
Where this is used in practice
- In ASP.NET (for example, when handling page lifecycle events),
- In WPF/WinForms for UI (for example, button clicks),
- In server-side programming (for example, logic inside a pipeline),
- In tests, when the handler doesn't need a separate name.
Syntax: what it looks like in code
Let's first look at a regular event handler:
// Event declaration
public event EventHandler? MyEvent;
// Subscribing to the event with a regular method
void Handler(object? sender, EventArgs e)
{
Console.WriteLine("The event occurred!");
}
public void Subscribe()
{
MyEvent += Handler;
}
Now — the variant with a lambda expression (anonymous function):
public void Subscribe()
{
MyEvent += (sender, e) => Console.WriteLine("The event occurred (lambda)!");
}
Notice: we don't create a separate method, we provide the handler right at subscription. The lambda's signature automatically matches the event type (EventHandler).
Comparing approaches
| Regular method | Lambda | |
|---|---|---|
| Amount of code | More (method + subscription) | Less, everything in place |
| Reusability | Can be reused | Usually not |
| Locality of code | Spread out | Everything nearby |
| Clarity/readability | Good for complex logic | Great for simple cases |
2. App with event handling and lambdas
Basic structure
public class Menu
{
public event EventHandler? ItemSelected;
public void SelectItem(int index)
{
Console.WriteLine($"Menu item {index} selected.");
ItemSelected?.Invoke(this, EventArgs.Empty);
}
}
Subscription with a lambda expression
class Program
{
static void Main()
{
var menu = new Menu();
// Subscribe to the event with a lambda
menu.ItemSelected += (sender, e) =>
{
Console.WriteLine("Thanks for your choice! Lambda handler fired.");
};
menu.SelectItem(1);
}
}
Expected output:
Menu item 1 selected.
Thanks for your choice! Lambda handler fired.
Capturing variables from outer context
One advantage of a lambda expression is that it can "remember" values from the outer scope (variable scope). For example, counting how many times a menu item was selected: the variable counter will be captured by the closure.
static void Main()
{
var menu = new Menu();
int counter = 0;
menu.ItemSelected += (s, e) =>
{
counter++;
Console.WriteLine($"Item selected {counter} time(s)!");
};
menu.SelectItem(1);
menu.SelectItem(2);
}
Expected output:
Menu item 1 selected.
Item selected 1 time(s)!
Menu item 2 selected.
Item selected 2 time(s)!
This is the magic of closures in action: the variable counter continues to live inside the lambda!
Example with event parameters
If your event uses EventHandler<T>, where T is a custom class with additional info, the lambda simply "adapts" to the required signature.
public class MenuItemSelectedEventArgs : EventArgs
{
public int ItemIndex { get; }
public string Description { get; }
public MenuItemSelectedEventArgs(int itemIndex, string description)
{
ItemIndex = itemIndex;
Description = description;
}
}
public class Menu
{
public event EventHandler<MenuItemSelectedEventArgs>? ItemSelected;
public void SelectItem(int index, string description)
{
Console.WriteLine($"Menu item {index}: {description} selected.");
ItemSelected?.Invoke(this, new MenuItemSelectedEventArgs(index, description));
}
}
// Usage
static void Main()
{
var menu = new Menu();
// Lambda that unpacks event arguments
menu.ItemSelected += (sender, args) =>
{
Console.WriteLine($"Selected item #{args.ItemIndex}: {args.Description.ToUpper()}");
};
menu.SelectItem(3, "About");
}
Expected output:
Menu item 3: About selected.
Selected item #3: ABOUT
3. Local handlers and lambdas
Lambdas are ideal when:
- Handling logic is short and clear,
- The handler is used only in one place,
- You need to "close over" variables from the local context.
If the handling is complex, needs reuse, or might be invoked outside the declaration site, it's better to use a separate named method.
Example: logic inside vs outside
Lambda (ideal):
button.Click += (s, e) => MessageBox.Show("Button pressed!");
Methods (when logic is more complex or reused):
button.Click += Button_Click;
void Button_Click(object sender, EventArgs e)
{
if (UserConfirmed())
{
SaveData();
MessageBox.Show("Data saved!");
}
}
4. Under the hood: what happens with lambda handlers
A lambda is also a delegate, like a regular handler. The compiler creates an anonymous method "on the fly", and if it has captured variables, also a hidden class to store them.
Important to remember (a common mistake): if you create a lambda inside a loop and subscribe it to an event, all iterations may capture the same loop variable.
for (int i = 0; i < 5; i++)
{
buttons[i].Click += (sender, e) =>
{
Console.WriteLine($"Click on button #{i}");
};
}
For all handlers the button number might end up being 5! To avoid this, create a copy of the variable inside the loop:
for (int i = 0; i < 5; i++)
{
int buttonIndex = i; // Local copy
buttons[i].Click += (sender, e) =>
{
Console.WriteLine($"Click on button #{buttonIndex}");
};
}
Now it works as expected.
The real power of lambda handlers: real life
Such lambda expressions save time and speed up development, especially when logic is simple. They keep code "close to the task", not scattered across files and classes. In real projects you'll find them everywhere: from UI event handling to subscribing to messages in asynchronous event buses.
5. Common mistakes
Mistake #1: forgetting to unsubscribe (-=) from long-lived objects.
If a subscriber object doesn't unsubscribe from a publisher's event, the delegate reference keeps the subscriber alive — the garbage collector can't collect it even if there are no other references to the object. This leads to memory "leaks" and dangling dependencies, especially when the publisher lives long (static events, singletons, services).
How to avoid: always unsubscribe when disposing/destroying (for example, Dispose, OnDisable, OnDestroy). Consider weak references (weak events / WeakEventManager), using an "event manager" pattern or IObservable/Rx if the scenario is complex.
Mistake #2: capturing loop variables without a local copy.
A common trap is to write subscription inside a loop and the closure captures the same loop variable, so all handlers see its final value, not the value at handler creation. This leads to unexpected results (all handlers "print" the same number, etc.).
How to avoid: create a local copy of the value inside the loop and capture it:
for (int i = 0; i < n; i++)
{
int current = i;
button.Click += (s, e) => Handle(current);
}
Or pass the needed value into a wrapper method. That's more reliable and makes the intent clear.
Mistake #3: using bulky lambdas at the subscription site.
If you place large business logic directly in an anonymous function at subscription, the code becomes hard to read, difficult to test and debug. Also — with anonymous lambdas it's harder to unsubscribe correctly because you need the same delegate instance. As a result the logic spreads and loses structure.
How to avoid: move complex logic to named methods or services; if necessary store the delegate in a variable/field so you can unsubscribe; keep only a small wrapper/redirect in the lambda. This will improve readability and maintainability.
GO TO FULL VERSION