Raising Events in C#: Syntax, Safety, and Common Pitfalls
Raising events in C#: declare and invoke them safely, pass custom event arguments, handle no subscribers, and avoid common pitfalls that break production code.
Raising an event in C# is straightforward once you understand the declaration, the invocation syntax, and the checks needed when no subscribers are present. The examples below cover the core pattern, custom event arguments, safe invocation from worker threads, and common pitfalls.
The Basic Event Pattern
In C#, an event is a member that allows subscribers to attach handler methods. The declaration uses the event keyword with a delegate type, typically EventHandler or EventHandler<T>. To raise the event, you invoke it like a method, passing the sender and event arguments. Only code inside the declaring type can raise the event; subscribers outside the type can only add or remove handlers.
public class Button { public event EventHandler? Clicked; public void SimulateClick() { Clicked?.Invoke(this, EventArgs.Empty); } }
The ?.Invoke syntax checks whether the event has any subscribers before calling. If there are none, the invocation is skipped. This is the standard way to raise an event in modern C#.
Creating Custom Event Arguments
When an event needs to carry data, derive a class from EventArgs and add properties. This keeps the event signature stable and avoids breaking subscribers when new data is added later.
public class TemperatureChangedEventArgs : EventArgs { public double OldTemperature { get; } public double NewTemperature { get; } public TemperatureChangedEventArgs(double oldTemp, double newTemp) { OldTemperature = oldTemp; NewTemperature = newTemp; } }
Then declare the event with EventHandler<TemperatureChangedEventArgs> and raise it with a constructed argument object. Subscribers can read the properties without casting.
Raising Events Safely with the Null-Conditional Operator
The null-conditional operator ?. is the recommended way to raise an event. It reads the current delegate value once and invokes it only if that value is non-null, preventing a NullReferenceException when no subscribers exist.
public void UpdateTemperature(double newTemp) { var oldTemp = _temperature; _temperature = newTemp; TemperatureChanged?.Invoke(this, new TemperatureChangedEventArgs(oldTemp, newTemp)); }
The delegate value read by ?.Invoke is a snapshot of the subscriber list at that moment. If a subscriber is removed after the read, it still receives this invocation; if a subscriber is added after the read, it does not. This behavior is usually acceptable for event notifications.
Thread Safety and Event Raising
When events are raised from multiple threads, use the same snapshot reasoning. The older local-copy pattern is equivalent to ?.Invoke: var handler = TemperatureChanged; reads the current delegate value once and stores that snapshot.
var handler = TemperatureChanged; if (handler != null) { handler(this, new TemperatureChangedEventArgs(oldTemp, newTemp)); }
Neither pattern is a synchronization mechanism. If you need to coordinate event subscription and publication so that a subscriber cannot be invoked after it is removed, you need custom event accessors with a shared lock or another synchronization design. Be careful not to hold the lock while invoking handlers: a handler that tries to acquire the same lock will deadlock.
Raising Events from Background Threads
If an event is raised from a background thread and subscribers expect to update the UI, marshal the invocation to the UI thread. Capture the SynchronizationContext before starting the background work, then use Post to invoke the event on that context.
public void StartBackgroundWork() { var context = SynchronizationContext.Current; Task.Run(() => { // ... do work ... context?.Post(_ => { EventRaised?.Invoke(this, EventArgs.Empty); }, null); }); }
This pattern is common in desktop and mobile applications. In ASP.NET Core, SynchronizationContext.Current is often null, so the pattern is less relevant there. The event itself is not thread-aware; the raising code must decide how to deliver the invocation.
Common Mistakes When Raising Events
One frequent mistake is forgetting the null check, which causes a NullReferenceException when no subscribers exist. Another is passing null as the sender for an instance event; the sender should be this unless there is a strong reason otherwise. Also, modifying event arguments after the event has been raised can confuse subscribers, especially if the same arguments object is reused.
Another subtle issue is raising an event while holding a lock. If a subscriber tries to acquire the same lock, the result is a deadlock. Copy the necessary state and raise the event outside the lock.
Choosing Between EventHandler<T> and Custom Delegate
EventHandler<T> is the .NET standard and should be your default choice for most events. It provides a consistent (object? sender, TEventArgs e) signature. A custom delegate is rarely needed, and a delegate that returns a value is a poor fit for standard multicast events because the publisher receives only the last handler's return value. For scenarios such as cancellation, prefer the standard CancelEventArgs type or an explicit handler collection or pipeline instead of a standard event.