Events
An event lets an object announce that something happened — a button was clicked, a download finished, a price changed — without knowing who's interested. Other objects subscribe to the event and get called when it's raised. This publish/subscribe pattern keeps classes loosely coupled, and it's built into C# with the event keyword.
Declaring, subscribing and raising
An event is declared with a delegate type — normally EventHandler or EventHandler<T>. Subscribers attach handlers with +=; the publisher raises the event by invoking it:
var kettle = new Kettle(); kettle.Boiled += (sender, e) => Console.WriteLine("Handler 1: time for tea!"); kettle.Boiled += (sender, e) => Console.WriteLine("Handler 2: switching off the light"); kettle.Heat(); class Kettle { public event EventHandler? Boiled; public void Heat() { Console.WriteLine("Heating..."); Boiled?.Invoke(this, EventArgs.Empty); } }
Heating... Handler 1: time for tea! Handler 2: switching off the light
The event is null until someone subscribes, so it's raised with ?.Invoke. By convention, handlers receive two arguments: the sender (the object that raised the event) and an EventArgs object with details.
Passing data with EventArgs
To send information with the event, create a class derived from EventArgs (or a record) and use EventHandler<T>:
var stock = new Stock("ACME", 100m); stock.PriceChanged += OnPriceChanged; stock.Price = 104.5m; stock.Price = 104.5m; // no change: no event stock.Price = 97.25m; static void OnPriceChanged(object? sender, PriceChangedEventArgs e) { string arrow = e.NewPrice > e.OldPrice ? "▲" : "▼"; Console.WriteLine($"{((Stock)sender!).Symbol} {arrow} {e.OldPrice} -> {e.NewPrice}"); } class PriceChangedEventArgs(decimal oldPrice, decimal newPrice) : EventArgs { public decimal OldPrice { get; } = oldPrice; public decimal NewPrice { get; } = newPrice; } class Stock(string symbol, decimal price) { public string Symbol { get; } = symbol; public event EventHandler<PriceChangedEventArgs>? PriceChanged; public decimal Price { get => price; set { if (value == price) return; decimal old = price; price = value; OnPriceChanged(new PriceChangedEventArgs(old, value)); } } protected virtual void OnPriceChanged(PriceChangedEventArgs e) => PriceChanged?.Invoke(this, e); }
ACME ▲ 100 -> 104.5 ACME ▼ 104.5 -> 97.25
Raising the event from a protected virtual OnEventName method is a .NET convention: it gives derived classes a way to react to or customise the event.
Unsubscribing
Remove a handler with -=. To unsubscribe later, you need a reference to the same handler, so use a named method or keep the lambda in a variable:
var alarm = new Alarm(); EventHandler handler = (s, e) => Console.WriteLine("Beep!"); alarm.Rang += handler; alarm.Ring(); alarm.Rang -= handler; alarm.Ring(); Console.WriteLine("(silence)"); class Alarm { public event EventHandler? Rang; public void Ring() => Rang?.Invoke(this, EventArgs.Empty); }
Beep! (silence)
Forgetting to unsubscribe is a classic source of memory leaks: the publisher keeps a reference to every subscriber, so a long-lived publisher keeps short-lived subscribers alive.
Why event and not just a delegate?
You could use a public delegate field instead, but the event keyword adds important protection. Outside the class, code can only += and -=; it can't raise the event or wipe out other subscribers:
var button = new Button(); button.Clicked += (s, e) => Console.WriteLine("clicked"); button.Clicked = null; button.Clicked(button, EventArgs.Empty); class Button { public event EventHandler? Clicked; }
Program.cs(3,8): error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button') Program.cs(4,8): error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')
A complete example: a download progress
Events let one class report progress to any number of listeners — a console display, a log, a UI — none of which the downloader knows about:
var downloader = new Downloader(); int lastShown = -1; downloader.ProgressChanged += (s, percent) => { if (percent / 25 != lastShown / 25) { Console.WriteLine($"[{new string('#', percent / 10),-10}] {percent}%"); lastShown = percent; } }; downloader.Completed += (s, e) => Console.WriteLine("Download complete"); downloader.Download(totalBytes: 2000, chunk: 100); class Downloader { public event EventHandler<int>? ProgressChanged; public event EventHandler? Completed; public void Download(int totalBytes, int chunk) { for (int done = chunk; done <= totalBytes; done += chunk) { ProgressChanged?.Invoke(this, done * 100 / totalBytes); } Completed?.Invoke(this, EventArgs.Empty); } }
[## ] 25% [##### ] 50% [####### ] 75% [##########] 100% Download complete
Since .NET Core, EventHandler<T> accepts any type for T, so simple events can pass a plain value such as an int.
Where you'll meet events
Events are central to desktop and mobile UI frameworks (button clicks, text changes) and appear throughout .NET — for example Console.CancelKeyPress, FileSystemWatcher.Changed and AppDomain.ProcessExit. In web back ends you'll see them less; there, async methods and message queues usually do the job.
Try it yourself
Give Thermostat a TooHot event that fires when the temperature rises above a limit, and subscribe two handlers.
Reading: 22.5 Reading: 25.9 Reading: 27.1 Warning: 27.1 °C! Turning on the fan. Reading: 24