Back to Blog
C#

C# Action Delegate Usage with Examples

c# action delegate: Learn how to use the C# Action delegate to pass methods as parameters, handle callbacks, and create flexible, reusable code with practical examples.

delegateslambda expressionsmethod groupshigher-order functionsevent handling
A visual metaphor for the C# Action delegate showing a callable method box that returns void, with arrows representing input parameters.

The c# action delegate is a built-in delegate type that represents a method that does not return a value. It is part of the System namespace and has several overloads, from Action with no parameters to Action<T1, T2, ..., T16> with up to sixteen parameters. Unlike Func<TResult>, which requires a return value, Action is designed for operations that produce a side effect, such as modifying state, writing to a log, or notifying a subscriber.

Consider a common scenario: you have a method that performs some processing and needs to notify the caller when it finishes. Instead of introducing a custom delegate or an interface, you can accept an Action parameter and invoke it at the appropriate point. This keeps the method decoupled from the calling code and allows the caller to specify exactly what happens on completion.

public static void ProcessData(int[] data, Action<string> onComplete) { // Simulate processing. foreach (var item in data) { Console.WriteLine(item); } onComplete("Processing finished"); }

When you call ProcessData, you provide the callback as a lambda expression, a method group, or an anonymous method. The following example uses a lambda that writes to the console:

int[] numbers = { 1, 2, 3 }; ProcessData(numbers, message => Console.WriteLine(message));

The Action delegate is useful for dependency injection, event-like notifications, and callbacks without defining a custom delegate type. Because it is a standard type, it is immediately recognizable to other C# developers and avoids the ceremony of declaring a delegate for every method signature.

The Core Syntax of the Action Delegate

The Action delegate is a generic type. The no-parameter version is simply Action. The single-parameter version is Action<T>, and the two-parameter version is Action<T1, T2>. In general, Action<T1, ..., Tn> represents a method that takes n parameters and returns void.

Here is the declaration for the three-parameter version:

public delegate void Action<in T1, in T2, in T3>(T1 arg1, T2 arg2, T3 arg3);

The in keyword indicates that the type parameters are contravariant, meaning you can assign a method that accepts a less derived type to an Action that expects a more derived type. For example, an Action<object> can hold a method that takes an object, and it can be used where an Action<string> is expected because any string is also an object.

When you create an Action instance, you can assign a method group directly. For instance:

void Log(string message) { Console.WriteLine($"[LOG] {message}"); } Action<string> writeLog = Log; writeLog("Application started");

This is equivalent to writing writeLog = (string message) => Log(message); but is more concise. Method groups are often the clearest way to pass an existing method to a parameter that expects an Action.

Using Action with Lambda Expressions

Lambda expressions are the most common way to create an Action instance, especially when the logic is short and does not need to be reused elsewhere. The compiler can infer the parameter types from the delegate type, so you do not need to specify them explicitly.

Action<int> incrementAndPrint = x => { x++; Console.WriteLine(x); }; ```n This works because the lambda body does not return a value; it only performs statements. The `Action` delegate expects a method that returns `void`, and the lambda is automatically converted to that signature. Lambda expressions also allow you to capture variables from the enclosing scope. This is useful for callbacks that need access to local state. For example: ```csharp int total = 0; Action<int> addToTotal = value => total += value; addToTotal(5); addToTotal(10); Console.WriteLine(total); // Outputs 15

The captured variables are stored in a closure, which means the lambda can read and modify them even after the method that created it has returned. This behavior is implicit and can be surprising in asynchronous scenarios, so you should be aware of the lifetime of captured variables.

Action as a Method Parameter

One of the primary uses of the Action delegate is passing behavior into a method. This allows the method to execute a piece of logic at a specific time, such as after an operation completes or when an error occurs. The method does not need to know the details of the callback; it only knows the signature.

Consider a retry helper that executes an operation and retries it if it throws an exception. The operation is passed as an Action that may throw.

public static void Retry(Action operation, int maxAttempts, int delayMilliseconds) { for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { operation(); return; } catch (Exception ex) when (attempt < maxAttempts) { Console.WriteLine($"Attempt {attempt} failed: {ex.Message}"); Thread.Sleep(delayMilliseconds); } catch (Exception ex) { Console.WriteLine($"Final attempt failed: {ex.Message}"); throw; } } }

You can then call it with a lambda that makes a network request or performs a file operation:

Retry(() => { // Example: delete a temporary file. File.Delete(tempPath); }, maxAttempts: 3, delayMilliseconds: 500);

The Action parameter does not return a value, which is appropriate here because the operation either succeeds or throws. If the operation needed to return a result, you would use a Func<TResult> instead.

Action vs Func vs Custom Delegates

Choosing between Action, Func, and custom delegates depends on whether you need a return value and whether you need a meaningful name for the delegate. Action is appropriate for callbacks that have no return value. Func<TResult> is appropriate when the callback must return a value. Custom delegates are useful when the delegate represents a domain-specific concept and the signature is likely to change; however, they add boilerplate and are less flexible than the built-in types.

Here is a quick comparison:

DelegateReturns ValueCommon Use Cases
ActionNoEvent notifications, completion callbacks
Func<TResult>YesSelectors, transformations, predicates
CustomEitherNames a specific signature, often with semantics

In most modern C# code, you can replace a custom delegate with an Action or Func unless the delegate name adds clarity. For instance, a delegate named ProgressReporter might be clearer than Action<int> when the meaning of the integer parameter is not obvious. However, you can achieve similar clarity by using a named method with a well-chosen parameter name.

Handling Errors Inside Action Callbacks

When you pass an Action to a method, both the caller and the callee need to agree on error handling. If the callback throws an exception, it will propagate up to the method that invoked the delegate, unless that method catches it. If you are using the delegate for asynchronous or event-like purposes, an unhandled exception may crash the application or be swallowed by a thread pool, depending on the context.

For example, if you invoke an Action inside a try block, you can catch exceptions from the callback:

public static void SafeExecute(Action action) { try { action(); } catch (Exception ex) { Console.WriteLine($"Callback failed: {ex.Message}"); } }

This approach is useful when you want the host method to remain robust even if the callback fails. In contrast, if the callback is expected to be reliable, letting exceptions propagate is often better because it surfaces bugs early.

Another consideration is that a single Action delegate can be a multicast delegate, meaning it can reference multiple methods. When you add multiple methods using +=, invoking the delegate calls all of them in order. If any method throws an exception, the invocation stops, and subsequent methods are not called. This is important for event-like patterns where listeners should not interfere with each other. If you need all listeners to run even if one throws, you should iterate over the methods manually using GetInvocationList().

Action<int> logger = null; logger += (x) => Console.WriteLine($"Logger A: {x}"); logger += (x) => { throw new InvalidOperationException("B fails"); }; logger += (x) => Console.WriteLine($"Logger C: {x}"); foreach (Action<int> subscriber in logger.GetInvocationList()) { try { subscriber(1); } catch (Exception ex) { Console.WriteLine($"Subscriber error: {ex.Message}"); } }

This pattern gives you fine-grained control over exception handling in multicast scenarios.

Using Action with Asynchronous Code

When dealing with asynchronous operations in C#, you often need to pass a callback that may itself be asynchronous. The Action delegate is not designed for returning a Task, so you cannot directly await inside it. Instead, you should use Func<Task> or Func<Task<TResult>> for asynchronous callbacks. If you pass an async void lambda to an Action, exceptions thrown inside that lambda will be rethrown on the synchronization context and are difficult to catch.

For example, the following code is problematic:

async void DoWork() { await Task.Delay(100); Console.WriteLine("Work done"); } Action action = DoWork;

Here, DoWork is an async void method, and its exceptions cannot be caught by the caller. This is a common source of unhandled exceptions. Instead, use a Func<Task> and await it in the calling method.

public static async Task RunAsync(Func<Task> operation) { await operation(); }

When you are using Action purely for side effects that involve asynchronous operations, you must think about the threading model. If the callback is invoked on a thread-pool thread and it uses async void, exceptions will be posted to the synchronization context. In console applications, that may crash the process. In UI applications, it may cause the app to crash unless you handle the TaskScheduler.UnobservedTaskException event.

Therefore, the rule of thumb is: Action for synchronous callbacks, Func<Task> for asynchronous callbacks.

Performance and Allocation Considerations

Creating an Action instance from a lambda expression may involve creating a delegate and, if the lambda captures variables, allocating a closure object. In performance-critical code paths, such as a hot loop that creates a new Action for each iteration, this can add GC pressure. However, modern .NET runtimes optimize many delegate allocations; for instance, static lambdas that do not capture variables are cached.

If you are concerned about allocations, consider caching the Action instance when the delegate does not change. For example, in a logging abstraction, you might store an Action<string> in a static field rather than creating it repeatedly.

private static readonly Action<string> LogAction = msg => Console.WriteLine(msg);

This reduces redundant delegate instances. If you are capturing variables, the compiler creates a new closure per invocation, which cannot be avoided unless you restructure the code to avoid capture. In such cases, consider passing the captured values as parameters to the Action instead.

Another performance aspect is multicast delegate invocation. When you have multiple subscribers, invoking the delegate calls each method in sequence, which is fine for a small number of callbacks. For high-frequency events with many subscribers, a custom event implementation might be more efficient, but that should be profiled rather than assumed.

Overall, the Action delegate is designed for general use and its performance is usually acceptable. Only optimize when profiling shows a bottleneck.

Common Pitfalls and How to Avoid Them

One frequent mistake is forgetting that Action does not return a value. If a method needs a result, using Action instead of Func causes a compile-time error. Always verify the delegate signature matches the intended use.

Another pitfall is using async void with Action. This can cause unobserved exceptions and unpredictable behavior. Avoid async void except for event handlers. For callbacks that involve await, prefer Func<Task>.

Also, when passing an Action as a parameter, beware of capturing loop variables. In older versions of C#, a lambda that captures a loop variable would capture the variable itself, not a snapshot. In C# 5 and later, the loop variable is considered a new variable for each iteration, so this is no longer an issue. However, if you are using a foreach loop with older language versions, you may need to assign a local copy.

Finally, when using multicast delegates, the order of invocation is the order of subscription. If you remove a method using -=, it works only if the exact method reference is used. With lambdas, you need to store the delegate in a variable to remove it later.

Action handler = null; Action myHandler = () => Console.WriteLine("Handler"); handler += myHandler; // ... handler -= myHandler;

This avoids silently failing to remove the handler.

When to Choose Another Delegate Type

The Action delegate is not always the best choice. If you need a return value, use Func. If you are defining a public API and the delegate has a meaningful semantic, a custom delegate can improve readability. For example, a delegate named Filter<T> might be clearer than Func<T, bool>.

Additionally, the Predicate<T> delegate is a specialized Func<T, bool> for testing conditions. It is essentially the same as Func<T, bool>, but the name conveys intent. While Action is broad, specialized delegates make your code more self-documenting.

Another modern alternative is to use method group conversions directly to Action without intermediate variables, which is common in LINQ and event handling. The choice of delegate type should be guided by clarity and the needs of the consuming API.

A Practical Example: Notification Service

Let's build a small notification service that uses an Action<string> to send messages. This demonstrates how Action can be injected into a class to decouple the notification mechanism.

public class NotificationService { private readonly Action<string> _notify; public NotificationService(Action<string> notify) { _notify = notify; } public void Send(string message) { _notify(message); } }

In a console application, you could inject a delegate that writes to the console:

var service = new NotificationService(msg => Console.WriteLine($"Notification: {msg}")); service.Send("Hello, world!");

This allows you to replace the notification behavior without changing the NotificationService class. In a test, you could pass an Action that records messages instead of sending them:

var messages = new List<string>(); var testService = new NotificationService(msg => messages.Add(msg)); testService.Send("Test"); Console.WriteLine(messages.Count); // Outputs 1

The use of Action here simplifies dependency injection because you do not need to create an interface with a single method. The delegate type captures the contract succinctly.

For more complex scenarios, such as sending notifications asynchronously, you would replace Action<string> with Func<string, Task> to allow await. This reinforces the guideline that Action is for synchronous side effects.

c# action delegate: Practical Usage and Code Examples | RYUSLOG DEV