C# Events
An event in C# is a controlled notification that one object sends when something important happens. Events let a publisher announce a change without knowing which other objects care about it, so they are central to user interfaces, background services, domain models, and many .NET libraries.
Events are built on delegates, but they add an important rule: outside code can subscribe and unsubscribe, yet only the declaring type can raise the event. That keeps the publisher in control of when the notification happens.
Overview: How Events Work
An event has two sides: the publisher, which declares and raises the event, and the subscribers, which attach handlers with +=. A handler is just a method or lambda whose signature matches the event delegate. When the publisher raises the event, C# invokes every subscribed handler in the event delegate’s invocation list.
Most .NET events use EventHandler or EventHandler<TEventArgs>. The first parameter is conventionally object? sender, the object that raised the event. The second parameter carries event data. For an event with no extra data, use EventArgs.Empty. For an event with useful details, create a class derived from EventArgs, or use an existing event-args type if one already fits.
Internally, a field-like event such as public event EventHandler? Clicked; is backed by a delegate field plus compiler-generated add and remove accessors. Subscribers call those accessors through += and -=. The backing delegate is multicast, so it can point to zero, one, or many handlers. Delegate instances are immutable; each subscription creates a new combined delegate and stores it back in the event field.
The event keyword is not just decoration. If a member were exposed as a public delegate field, outside code could replace all handlers, clear the invocation list, or raise the notification at the wrong time. An event prevents that. Code outside the declaring class can only add or remove handlers; it cannot directly assign to the event or call Invoke on it.
Events are synchronous by default. When the publisher invokes an event, handlers run immediately on the same thread, one after another, in subscription order. If a handler is slow, the publisher waits. If a handler throws an exception and the publisher does not catch it, later handlers are skipped and the exception bubbles back to the caller.
Syntax
public class Publisher
{
public event EventHandler? SomethingHappened;
protected virtual void OnSomethingHappened()
{
SomethingHappened?.Invoke(this, EventArgs.Empty);
}
}
publisher.SomethingHappened += Handler;
publisher.SomethingHappened -= Handler;
| Part | Meaning |
|---|---|
event |
Declares a restricted delegate member that outside code can subscribe to but cannot raise. |
EventHandler? |
The delegate type for a notification with no custom data. The question mark allows no subscribers. |
SomethingHappened |
The event name, usually written as a past-tense or state-change phrase. |
OnSomethingHappened |
A common protected method that raises the event from inside the publisher. |
+= and -= |
Subscribe and unsubscribe handlers. |
For public .NET-style APIs, prefer EventHandler for simple notifications and EventHandler<TEventArgs> when subscribers need data. Custom delegate types are still allowed, but the standard pattern makes code easier for other C# developers to read.
Examples
A Basic Event
using System;
public class Counter
{
public event EventHandler? ThresholdReached;
public int Value { get; private set; }
public void Add(int amount)
{
Value += amount;
Console.WriteLine($"Counter is now {Value}");
if (Value >= 10)
{
ThresholdReached?.Invoke(this, EventArgs.Empty);
}
}
}
class Program
{
static void Main()
{
Counter counter = new Counter();
counter.ThresholdReached += HandleThresholdReached;
counter.Add(4);
counter.Add(6);
}
static void HandleThresholdReached(object? sender, EventArgs e)
{
Console.WriteLine("Threshold reached!");
}
}
Output:
Counter is now 4
Counter is now 10
Threshold reached!
Counter publishes ThresholdReached. The program subscribes HandleThresholdReached, then calls Add. When the value reaches 10, the publisher invokes the event. The subscriber decides what to do with that notification; the counter does not need to know.
Passing Event Data with EventArgs
using System;
public class TemperatureChangedEventArgs : EventArgs
{
public TemperatureChangedEventArgs(double oldValue, double newValue)
{
OldValue = oldValue;
NewValue = newValue;
}
public double OldValue { get; }
public double NewValue { get; }
}
public class Thermostat
{
private double _temperature;
public event EventHandler<TemperatureChangedEventArgs>? TemperatureChanged;
public double Temperature
{
get => _temperature;
set
{
if (_temperature == value)
{
return;
}
double oldValue = _temperature;
_temperature = value;
OnTemperatureChanged(oldValue, value);
}
}
protected virtual void OnTemperatureChanged(double oldValue, double newValue)
{
TemperatureChanged?.Invoke(this, new TemperatureChangedEventArgs(oldValue, newValue));
}
}
class Program
{
static void Main()
{
Thermostat thermostat = new Thermostat();
thermostat.TemperatureChanged += LogTemperature;
thermostat.Temperature = 21.5;
thermostat.Temperature = 23.0;
thermostat.Temperature = 23.0;
}
static void LogTemperature(object? sender, TemperatureChangedEventArgs e)
{
Console.WriteLine($"Temperature changed from {e.OldValue:F1} to {e.NewValue:F1}");
}
}
Output:
Temperature changed from 0.0 to 21.5
Temperature changed from 21.5 to 23.0
This version carries useful data. TemperatureChangedEventArgs stores the old and new values, and the event type becomes EventHandler<TemperatureChangedEventArgs>. The property setter raises the event only when the value actually changes, which avoids noisy notifications.
Multiple Events and Unsubscribing
using System;
public class DownloadService
{
public event EventHandler<string>? ProgressChanged;
public event EventHandler? Completed;
public void Download()
{
ReportProgress("Connecting");
ReportProgress("Downloading file");
ReportProgress("Saving file");
Completed?.Invoke(this, EventArgs.Empty);
}
private void ReportProgress(string message)
{
ProgressChanged?.Invoke(this, message);
}
}
class Program
{
static void Main()
{
DownloadService service = new DownloadService();
EventHandler<string> progressHandler = (sender, message) =>
Console.WriteLine($"Progress: {message}");
EventHandler completedHandler = (sender, e) =>
Console.WriteLine("Download complete");
service.ProgressChanged += progressHandler;
service.Completed += completedHandler;
service.Download();
service.ProgressChanged -= progressHandler;
service.Completed -= completedHandler;
}
}
Output:
Progress: Connecting
Progress: Downloading file
Progress: Saving file
Download complete
A realistic publisher often exposes more than one event. Here, subscribers can react to progress separately from completion. The handlers are stored in variables so the same delegate instances can be removed later with -=.
How Events Work Step by Step
- The compiler sees an event declaration and creates add/remove subscription logic around a delegate-backed member.
- A subscriber uses
+=. The add accessor combines the existing invocation list with the new handler. - The publisher reaches the point where the event should occur and calls
EventName?.Invoke(sender, args)from inside the declaring type. - The null-conditional operator checks whether there are subscribers. If there are none, nothing happens.
- If subscribers exist, the CLR invokes each handler in the multicast delegate’s invocation list synchronously.
- After
-=, the remove accessor builds a new invocation list without the matching handler instance.
The usual OnEventName method is more than a naming habit. It centralizes the raising logic, makes derived classes able to customize behavior with protected virtual, and keeps the public method or property focused on its own job. In sealed or very small classes you may raise the event directly, but the pattern scales well.
Common Mistakes
Trying to Raise Someone Else’s Event
using System;
public class Alarm
{
public event EventHandler? Ringing;
}
class Program
{
static void Main()
{
Alarm alarm = new Alarm();
alarm.Ringing?.Invoke(alarm, EventArgs.Empty);
}
}
This does not compile. Ringing can be invoked only from inside Alarm, because an event is not a public delegate field. Expose a method that performs the state change and raises the event from the publisher.
using System;
public class Alarm
{
public event EventHandler? Ringing;
public void Ring()
{
Ringing?.Invoke(this, EventArgs.Empty);
}
}
class Program
{
static void Main()
{
Alarm alarm = new Alarm();
alarm.Ringing += (sender, e) => Console.WriteLine("Alarm handled");
alarm.Ring();
}
}
Output:
Alarm handled
Unsubscribing with a Different Lambda
using System;
public class Button
{
public event EventHandler? Clicked;
public void Click()
{
Clicked?.Invoke(this, EventArgs.Empty);
}
}
class Program
{
static void Main()
{
Button button = new Button();
button.Clicked += (sender, e) => Console.WriteLine("Clicked");
button.Clicked -= (sender, e) => Console.WriteLine("Clicked");
button.Click();
}
}
Output:
Clicked
This compiles, but it does not unsubscribe the first lambda. The two lambda expressions look identical, but they create different delegate instances. Store the handler if you need to remove it later.
using System;
public class Button
{
public event EventHandler? Clicked;
public void Click()
{
Clicked?.Invoke(this, EventArgs.Empty);
}
}
class Program
{
static void Main()
{
Button button = new Button();
EventHandler handler = (sender, e) => Console.WriteLine("Clicked");
button.Clicked += handler;
button.Clicked -= handler;
button.Click();
Console.WriteLine("No handlers remain");
}
}
Output:
No handlers remain
Invoking Without Checking for Subscribers
using System;
public class Clock
{
public event EventHandler? Tick;
public void Advance()
{
Tick(this, EventArgs.Empty);
}
}
If no one subscribed, Tick is null, so direct invocation can throw NullReferenceException. Use ?.Invoke for the normal field-like event pattern.
using System;
public class Clock
{
public event EventHandler? Tick;
public void Advance()
{
Tick?.Invoke(this, EventArgs.Empty);
}
}
class Program
{
static void Main()
{
Clock clock = new Clock();
clock.Advance();
Console.WriteLine("Advanced safely");
}
}
Output:
Advanced safely
Best Practices
- Name events after what happened or is happening, such as
Completed,Changed, orProgressChanged. - Use
EventHandlerandEventHandler<TEventArgs>unless a custom delegate clearly improves the API. - Raise events from inside the publishing type, usually through a protected
OnEventNamemethod. - Use immutable event data objects so one subscriber cannot surprise another subscriber by changing shared data.
- Unsubscribe from long-lived publishers when the subscriber should be collectable; event subscriptions keep subscriber targets alive.
- Do not assume events are asynchronous. If handlers must run in the background, design that explicitly.
- Keep event handlers short and robust. One throwing handler can stop later handlers from running.
- Store lambda handlers in variables when you will need to unsubscribe them.
Practice Exercises
- Create a
BankAccountclass with aLowBalanceevent. Raise it when the balance drops below100. - Add a custom
ScoreChangedEventArgsclass withOldScoreandNewScoreproperties, then raise it from aPlayerclass. - Write a program with two subscribers to the same event. Unsubscribe one handler and prove that only the remaining handler runs.
Summary
- Events are delegate-based notifications with controlled subscription and invocation.
- The publisher declares and raises the event; subscribers attach handlers with
+=and remove them with-=. EventHandleris used for simple events, andEventHandler<TEventArgs>carries custom event data.- Events run synchronously unless you explicitly design asynchronous behavior.
- Use
?.Invoke, standard naming, and clear event data to make event APIs predictable. - Remember that subscriptions can affect object lifetime, so unsubscribe from long-lived publishers when needed.
