Back to Blog
C#

C# Event Declaration: Syntax, Behavior, and Pitfalls

Learn the syntax for declaring C# events, safe ways to raise them, and the difference between events and delegate fields.

C# eventsdelegatesevent handlersEventHandler<T>event accessors.NET
Illustration of a C# event declaration connecting an event source to multiple handler nodes.

Declaring an event in C# is straightforward, but the behavior around it is easy to get wrong. The core syntax is public event EventHandler MyEvent; — a field-like event that the compiler turns into a private delegate field plus a pair of accessor methods. This article covers the declaration syntax, how events differ from plain delegate fields, how to raise them safely, and when custom accessors are necessary.

The Basic Event Declaration Syntax

A field-like event is declared with the event keyword, a delegate type, and a name. The most common delegate type is EventHandler for events that carry no custom data, or EventHandler<TEventArgs> when you need to pass an argument.

public class Button { public event EventHandler? Click; public void SimulateClick() { Click?.Invoke(this, EventArgs.Empty); } }

The ? after EventHandler marks the backing delegate as nullable. The ?.Invoke pattern is the standard way to raise an event while checking for subscribers. Without the null-conditional operator, you would need an explicit null check:

public void SimulateClick() { var handler = Click; if (handler != null) { handler(this, EventArgs.Empty); } }

The local copy handler matters in multithreaded scenarios, as explained later.

How Events Differ from Delegate Fields

The event keyword restricts how the backing delegate can be used. Outside the declaring class, you can only attach or detach handlers with += and -=. You cannot invoke the event directly, nor can you assign to it. A public delegate field, by contrast, allows any caller to invoke it or replace the entire invocation list.

public class Example { public Action? NotAnEvent; // Delegate field: callers can invoke or replace public event Action? AnEvent; // Event: callers can only add/remove }

This restriction is the primary reason to choose an event over a delegate field. It protects the publisher's control over when the event is raised. The compiler enforces this at compile time, so accidental misuse is caught early.

Raising an Event Safely with the Null Check

A field-like event's backing delegate is null when no handlers are attached. Invoking a null delegate throws NullReferenceException, so a null check is mandatory. The ?.Invoke syntax is concise and safe: it reads the current delegate value once, checks it for null, and then invokes that snapshot.

public event EventHandler? Updated; public void RaiseUpdated() { Updated?.Invoke(this, EventArgs.Empty); }

If you prefer an explicit null check, copy the delegate into a local variable before testing it:

public void RaiseUpdated() { var handler = Updated; if (handler != null) { handler(this, EventArgs.Empty); } }

Without the local copy, code such as if (Updated != null) Updated(...) can read the field twice, and a handler removed after the check could make the second read null. The local-copy pattern avoids that race. The ?.Invoke syntax has the same snapshot behavior; the explicit local copy just makes it visible.

Using EventHandler<T> for Custom Data

When an event needs to carry data, use EventHandler<TEventArgs>. The type parameter must derive from EventArgs. The .NET convention is to create a dedicated class for the event arguments.

public class TemperatureChangedEventArgs : EventArgs { public double OldTemperature { get; } public double NewTemperature { get; } public TemperatureChangedEventArgs(double oldTemp, double newTemp) { OldTemperature = oldTemp; NewTemperature = newTemp; } } public class Thermostat { public event EventHandler<TemperatureChangedEventArgs>? TemperatureChanged; private double _temperature; public double Temperature { get => _temperature; set { if (value == _temperature) return; var old = _temperature; _temperature = value; TemperatureChanged?.Invoke(this, new TemperatureChangedEventArgs(old, value)); } } }

This pattern keeps the event declaration clean and provides strongly typed data to subscribers. Avoid using EventArgs.Empty when you need to pass meaningful information.

Custom Event Accessors with add and remove

Field-like events are sufficient for most cases, but sometimes you need custom storage or validation. You can declare an event with explicit add and remove accessors, similar to property accessors.

private readonly object _eventLock = new object(); private EventHandler? _customEvent; public event EventHandler? CustomEvent { add { lock (_eventLock) { _customEvent += value; } } remove { lock (_eventLock) { _customEvent -= value; } } }

Custom accessors are useful when you need to:

  • Store handlers in a custom collection or centralize many events in a dictionary keyed by event name.
  • Add synchronization around the add/remove operations.
  • Enforce business rules, such as rejecting duplicate handlers.
  • Log subscription changes.

Be aware that custom accessors bypass the compiler-generated backing field. You must manage the delegate storage yourself. If you use a field like _customEvent, you still need to raise it with the same null-check pattern.

Common Mistakes When Declaring Events

A frequent mistake is forgetting the null check and invoking the event directly. This throws NullReferenceException when no handlers are attached. Another mistake is exposing a delegate field instead of an event, allowing external code to invoke or reset the invocation list.

A more subtle mistake is raising an event on the wrong thread. Events do not automatically marshal to the UI thread. If a background thread raises an event that UI handlers subscribe to, those handlers run on the background thread. This is not a declaration problem, but it affects how you design the event invocation. The declaration itself should not assume a synchronization context.

Also, be careful with naming. The convention is to use a verb-based event name that describes the notification, such as Clicked or PropertyChanged. Avoid names that suggest a method, like ClickEvent or OnClick.

Performance and Overhead Considerations

Events are built on multicast delegates. Each subscription adds a delegate to the invocation list. Raising an event invokes every handler in the order they were added. The overhead of an event invocation is the cost of iterating the invocation list and calling each delegate. For most applications, this is negligible.

However, if you have an event with many subscribers and it is raised frequently, the invocation cost grows linearly. There is also a small allocation cost when adding or removing handlers because the delegate chain is immutable; += and -= create a new delegate instance. In high-frequency subscription scenarios, this can cause garbage collection pressure.

If you need to raise an event with zero subscribers, the null check avoids the invocation overhead entirely. The ?.Invoke pattern is efficient because it reads the delegate field once.

When to Use a Delegate Field Instead of an Event

Use an event when the publisher must retain control over invocation. Use a plain delegate field when you need to allow external code to invoke the delegate or replace it entirely. For example, a callback that a library invokes directly is often better as a delegate property. An event is appropriate for notifications that multiple subscribers can observe.

A delegate field also allows you to check for subscribers without the event syntax, but it loses the protection that events provide. In most cases, events are the correct choice for public APIs because they enforce the subscription model.

For internal implementation details, a delegate field may be simpler. But if the member is part of a public contract, prefer an event to prevent misuse.