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:

C#
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);
    }
}
Output
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>:

C#
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);
}
Output
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:

C#
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);
}
Output
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:

C#
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;
}
Build error
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:

C#
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);
    }
}
Output
[##        ] 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.

Try it yourself · Program.cs
Edit the code, copy it into Program.cs and run it with dotnet run Program.cs (.NET 10 or later). How to set up .NET
Output
Reading: 22.5
Reading: 25.9
Reading: 27.1
Warning: 27.1 °C!
Turning on the fan.
Reading: 24