Events Advanced
By the end of this lesson you'll be able to design professional, type-safe events: carry rich data with a custom EventArgs , raise events the standard .NET way with a protected virtual OnXxx method, do it safely on multiple threads, and avoid the memory leaks that catch out even experienced developers.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 Real-World Analogy
An event is a newsletter . The publisher sends out an issue without knowing who reads it; subscribers sign up ( += ) and cancel ( -= ) at will. A plain EventArgs.Empty is a blank postcard that just says "something happened". A custom EventArgs is the actual article stapled to the postcard — it carries the details: which order, how much, what the new temperature was. And the OnXxx method is the printing press in the basement: the only machine allowed to put an issue in the post, kept private so nobody outside can fake a mailing.
Running C# Locally: Install the .NET SDK or use dotnetfiddle.net to run every console example below.
📊 The Event Toolkit at a Glance
Piece
What it is
Example
When to use
EventHandler
Standard delegate, no extra data
event EventHandler Clicked;
Event carries nothing
EventHandler<T>
Standard delegate carrying data T
event EventHandler<OrderEventArgs> OrderPlaced;
Event carries details
: EventArgs
Your data class for an event
class OrderEventArgs : EventArgs
Define what to send
OnXxx(...)
protected virtual raise method
protected virtual void OnOrderPlaced(...)
The one place that raises
?.Invoke
Null-safe raise
OrderPlaced?.Invoke(this, e);
Always, when raising
+= / -=
Subscribe / unsubscribe
svc.OrderPlaced += OnPlaced;
From outside the class
The signature every handler matches is always (object sender, TEventArgs e) — sender is who raised it, e is the data. Memorising that one shape unlocks every event in .NET.
1. Custom EventArgs — Carrying Data With an Event
A bare event can shout "something happened!", but real apps need to know what happened. You attach that detail by creating a class that inherits from EventArgs and declaring the event as EventHandler < YourEventArgs > . By convention the data class ends in EventArgs and its properties are read-only (set once in the constructor) so a subscriber can't tamper with the data other subscribers will receive. Read this worked example and run it, then you'll write your own.
Your turn. Define an EventArgs subclass that carries a message, then raise an event with it. Fill in the three blanks marked ___ using the // 👉 hints, then run it.
2. Subscribing — Reading the Event Data
Defining the data is only half the job; a subscriber has to read it. Every handler receives two arguments: sender (the object that raised the event) and e (your EventArgs ). You reach the details through e — e.OrderId , e.Amount , e.Points , whatever your class exposed. Fill in the two blanks below: choose the operator that adds a subscriber, and pull the value off e .
3. The protected virtual OnXxx Raise Method
In professional code you almost never raise an event inline. Instead you funnel every raise through a single protected virtual method named On + the event name — OnPriceChanged , OnOrderPlaced . There are two reasons. protected lets a subclass raise the event too (handy for base classes). virtual lets a subclass override the method to add behaviour — log it, suppress it, run code before or after — without touching the property that triggered it. It also gives you exactly one place to make the raise thread-safe.
4. Thread-Safe Raising & Unsubscribing
There's a subtle race condition when events are used across threads. Between checking that an event has subscribers and actually calling it, another thread could unsubscribe the last handler — leaving you calling null . The fix is to copy the event into a local variable first , then call the copy. Good news: the ?.Invoke operator you've been using already does this copy for you, which is the main reason it's the recommended way to raise. The example also shows the other half of subscription hygiene: a named handler so you can -= it later (you can't unsubscribe an anonymous lambda).
🔎 Deep Dive: memory leaks & weak events
When you write publisher.SomeEvent += subscriber.Handler , the publisher now holds a reference to the subscriber. If the publisher lives a long time (a singleton, a static service, a long-running window) and you never call -= , the garbage collector cannot reclaim the subscriber — it's kept alive purely by that subscription. Repeat this in a loop and memory climbs forever. This is the single most common "managed memory leak" in C#.
The everyday fix is discipline: unsubscribe with -= when the subscriber is done, often inside Dispose() . For cases where you can't guarantee that — UI frameworks especially — there are weak event patterns where the publisher holds only a weak reference , so the subscriber can still be collected. WPF ships WeakEventManager ; many teams use a small WeakEventManager < T > helper for the same effect.
Pro Tips
- 💡 Always raise with ?.Invoke(...) : it both skips zero-subscriber events and takes the thread-safe local copy for you — two safety nets in one operator.
- 💡 Use the protected virtual OnXxx pattern so subclasses can extend the behaviour and there's exactly one place that raises the event.
- 💡 Keep EventArgs properties read-only: set them in the constructor so one subscriber can't mutate the data the next subscriber sees.
- 💡 Use a record for terse EventArgs (C# 9+): record OrderEventArgs(string Id, decimal Amount) : EventArgs; generates the constructor and properties for you.
- 💡 Name handlers so you can detach them: a named method or stored delegate can be passed to -= ; an inline lambda can't be unsubscribed.
Common Errors (and the fix)
- "System.NullReferenceException" when raising: an event with no subscribers is null , so OrderPlaced(this, e) crashes. Always raise it as OrderPlaced?.Invoke(this, e); — the ?. simply does nothing when nobody is listening.
- Forgetting to unsubscribe → memory leak: a subscriber a long-lived publisher still references can never be garbage-collected. Call -= when you're done (often in Dispose() ), passing the same handler reference you subscribed with.
- Wrong handler signature: for EventHandler < T > the handler must be (object sender, T e) . A method like void Handle(T e) won't compile — it's missing the sender . Match the standard (object sender, TEventArgs e) shape exactly.
- "CS0070: The event 'X' can only appear on the left hand side of += or -=": you tried to raise (or assign) an event from outside the declaring class. Only the class that declares the event may raise it — wrap the raise in a public / protected method and call that instead.
- EventArgs not inheriting from EventArgs: EventHandler < T > requires T to derive from EventArgs . If you forget : EventArgs , the event declaration won't compile.
📋 Quick Reference
Task
Code
Notes
Define event data
Read-only props
Declare a data event
On the publisher
Raise method
One place to raise
Raise safely
Null-safe + thread-safe
Thread-safe local copy
var h = OrderPlaced; h?.Invoke(this, e);
What ?.Invoke does
Handler signature
void H(object sender, OrderEventArgs e)
Always (sender, e)
svc.OrderPlaced += H; svc.OrderPlaced -= H;
Same reference to -=
Read the data
$"{e.OrderId}: {e.Amount:C}"
Inside the handler
Frequently Asked Questions
Q: Do I have to inherit from EventArgs for my data class?
To use the built-in EventHandler < T > delegate, yes — its constraint requires T : EventArgs . It's a tiny base class with no members, so inheriting costs nothing and signals "this is event data" to every C# developer and tool.
Q: Why use a protected virtual OnXxx method instead of raising inline?
It gives you one single place that raises the event, which keeps thread-safety logic in one spot. protected lets subclasses raise it, and virtual lets them override to add behaviour (logging, suppression) without rewriting the trigger. It's the convention every .NET library follows.
Q: Is ?.Invoke really enough for thread safety?
For the classic "subscriber unsubscribed between the null-check and the call" race, yes — ?.Invoke evaluates the event once into a hidden local, so it can't become null mid-call. It does not make the handlers themselves thread-safe; if your handlers touch shared state, they still need their own locking.
Q: Why can't I unsubscribe a lambda I subscribed with?
Each lambda you write is a distinct delegate object. x -= (s,e) => ... creates a brand-new lambda that doesn't match the one you added, so nothing is removed. Store the handler in a variable or use a named method, then pass that same reference to both += and -= .
Mini-Challenge: a TemperatureSensor
No blanks this time — just a brief and an outline. Build a TemperatureSensor that raises a ThresholdExceeded event (with a custom EventArgs carrying the value) when a reading goes above its threshold, and a subscriber that reacts with an alert. Run it and check your output against the expected lines in the comments.
🎉 Lesson Complete
- ✅ A custom EventArgs subclass carries data; keep its properties read-only
- ✅ EventHandler (no data) and EventHandler < T > (with data) are the standard event delegates
- ✅ Every handler matches the (object sender, TEventArgs e) signature
- ✅ Raise through a protected virtual OnXxx method — one place, easy to override
- ✅ Always raise with ?.Invoke : it's null-safe and takes the thread-safe local copy
- ✅ Unsubscribe with -= (same reference) to prevent memory leaks; weak events help when you can't
- ✅ Next lesson: Expression Trees — building and inspecting code as data at runtime
Practice quiz
What does a custom EventArgs subclass do?
- Replaces the event keyword
- Unsubscribes handlers automatically
- Carries data to every subscriber of an event
- Makes the event thread-safe by itself
Answer: Carries data to every subscriber of an event. A subclass of EventArgs carries the details (which order, how much, etc.) to every subscriber when the event is raised.
What is the standard generic event delegate that carries data?
- EventHandler<T>
- Action<T>
- Func<T>
- Delegate<T>
Answer: EventHandler<T>. EventHandler<T> is the standard generic event delegate; T must derive from EventArgs.
What signature must every handler for EventHandler<T> match?
- (T e)
- (T e, object sender)
- () with no parameters
- (object sender, T e)
Answer: (object sender, T e). Every handler matches (object sender, TEventArgs e) — sender is who raised it, e is the data.
Why raise an event with instead of calling it directly?
- It runs faster
- The ?. skips the call when there are zero subscribers (no NullReferenceException)
- It sorts the subscribers
- It unsubscribes afterwards
Answer: The ?. skips the call when there are zero subscribers (no NullReferenceException). An event with no subscribers is null; the null-conditional ?.Invoke does nothing when nobody is listening, avoiding a NullReferenceException.
Why are an EventArgs class's properties kept read-only?
- So one subscriber can't mutate the data the next subscriber sees
- For performance
- It's required by the compiler
- To allow lazy loading
Answer: So one subscriber can't mutate the data the next subscriber sees. Read-only properties (set once in the constructor) stop one subscriber from tampering with the data other subscribers will receive.
What is the purpose of the raise method pattern?
- To make the event public
- To avoid using EventArgs
- To give one place that raises the event, which subclasses can override
- To subscribe automatically
Answer: To give one place that raises the event, which subclasses can override. protected lets subclasses raise it, virtual lets them override how it's raised, and it funnels every raise through a single place.
Why copy an event into a local variable before raising it across threads?
- To make it faster
- Another thread could unsubscribe the last handler between the null-check and the call
- To duplicate the handlers
- To reset the event
Answer: Another thread could unsubscribe the last handler between the null-check and the call. A local copy can't be changed out from under you, so you won't end up calling null if another thread unsubscribes mid-raise. ?.Invoke does this copy for you.
Why can't you unsubscribe a handler you subscribed with as an inline lambda?
- Lambdas can't be used as handlers
- The += operator deletes the reference
- Lambdas are always static
- Each lambda is a distinct delegate object, so -= with a new lambda matches nothing
Answer: Each lambda is a distinct delegate object, so -= with a new lambda matches nothing. Each lambda you write is a new delegate object; -= with a fresh lambda doesn't match the one you added. Use a named method or stored delegate.
What is the most common managed memory leak involving events?
- Raising an event too often
- A long-lived publisher keeping a subscriber alive because you never called -=
- Using EventHandler<T>
- Declaring too many EventArgs classes
Answer: A long-lived publisher keeping a subscriber alive because you never called -=. publisher.Event += subscriber.Handler makes the publisher reference the subscriber; if the publisher outlives it and you never -=, the subscriber can't be collected.
What constraint does EventHandler<T> place on T?
- T must be a struct
- T must implement IDisposable
- T must derive from EventArgs
- T must be sealed
Answer: T must derive from EventArgs. EventHandler<T> requires T : EventArgs, which is why your data class must inherit from EventArgs.
Continue this course
- Previous: Deep Dive Into Delegates
- Next: Expression Trees