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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
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.
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 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).
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.
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 -= .
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.
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.