Back to Blog
C#

C# Event Declaration: Syntax, Behavior, and Pitfalls

c# event declaration: Learn how to declare and raise events in C# correctly, including syntax, thread-safety, and common mistakes.

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

c# event declaration requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

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 explains the declaration syntax, how events differ from plain delegate fields, how to raise them safely, and where custom accessors become 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 indicates that the backing delegate may be null. 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, but it has a subtle threading implication.

Consider this code running on multiple threads:

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

Between the null check and the invocation, another thread could remove the last handler, making the delegate null again. The null-conditional operator compiles to a single read of the field, but the invocation itself is not atomic with respect to handler removal. In practice, the delegate reference is copied to a local variable by the compiler, so the invocation operates on a snapshot. However, if you write an explicit null check, you must copy to a local variable yourself to avoid a race where the delegate becomes null between the check and the call.

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

This pattern is the recommended way to raise an event in a thread-safe manner. The ?.Invoke syntax is equivalent in most cases, but the explicit local copy makes the snapshot behavior clear.

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 EventHandler? _customEvent; public event EventHandler? CustomEvent { add { lock (this) { _customEvent += value; } } remove { lock (this) { _customEvent -= value; } } }

Custom accessors are useful when you need to:

  • Store handlers in a non-standard structure, such as 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 in the past tense, 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.

c# event declaration: Practical Usage and Code Examples | RYUSLOG DEV