The Dispatcher
π Dispatcher β Executing Actions in StatePulseβ
The dispatcher in StatePulse is responsible for preparing and executing actions in a clean and fluent way.
Unlike some traditional systems, the dispatch pattern here is more structured:
π§ Dispatch Flowβ
The standard flow for dispatching an action is:
Prepare<TAction>()
β creates the action instance.With(...)
β sets properties on the action.DispatchAsync()
β executes the action
This approach promotes immutability, clear intent, and safe updates.
β οΈ About Constructorsβ
You can pass constructor arguments in Prepare<T>()
, like this:
Dispatcher.Prepare<MyAction>(arg1, arg2);
But this is strongly discouraged, and ideally avoided completely.
β Why avoid it?
If the constructor changes later (e.g., parameter added, removed, or reordered),
the compiler wonβt warn you, and your dispatch logic may break silently at runtime.
This can lead to:
- Incorrect action initialization
- Subtle bugs
- Hard-to-trace state issues
Inject into Componentβ
public partial class Counter : ComponentBase
{
[Inject] private IDispatcher Dispatcher { get; set; }
private async Task Increment()
{
await Dispatcher.Prepare<IncrementCounterAction>()
.With(p => p.Delay, 1)
.DispatchAsync();
}
}
π οΈ Pre-initializing Actions (Optional)β
You can also pre-initialize an action manually β similar to how it's done in conventional state management systems.
This approach is useful when:
- You're using constructor-based action records
- You're dispatching actions in a loop, where minimizing per-dispatch overhead matters
β οΈ While the reflection overhead of
Prepare<T>()
is small, it does exist. In performance-critical scenarios (like loops or tight UI updates), pre-initializing the action can offer a slight efficiency gain.
public partial class Counter : ComponentBase
{
[Inject] private IDispatcher Dispatcher { get; set; }
private async Task Increment()
{
await Dispatcher.Prepare(()=> new IncrementCounterAction(){
Delay = 1
}).DispatchAsync();
}
}
π§΅ Safe Execution On-the-Flyβ
Every action in StatePulse can be executed on-the-fly as a safe action,
meaning any subsequent execution cancels the previous one.
This significantly reduces the risk of race conditions in async workflows.
β This is especially helpful for API calls, debounced interactions, or long-running processes.
β οΈ Use Safe Actions Selectivelyβ
While powerful, safe actions come with a small overhead footprint.
You should avoid using them for:
- Reducer-only actions (e.g., local counter updates)
- Instantaneous operations where no async delay exists
β οΈ Overusing
ISafeAction
can lead to performance degradation in large or complex UIs.
While it's unlikely you'll encounter direct issues in most apps,
why not save performance when it's that easy to do?
Use ISafeAction
only when the benefits β like cancellation, deduplication, or race condition protection β are necessary.
β³ Await Pipelineβ
You can await the dispatch pipeline, which means your code will block execution until the entire pipeline completes:
- All effects (including cascading effects) finish
- All reducers have executed
- The full state update cycle is done
This feature is not common in traditional Redux-style state management,
which often relies on external tools (like Redux DevTools) to observe state changes asynchronously.
Why Awaiting Matters in .NET / StatePulseβ
- The .NET environment encourages step-by-step debugging, which is often the most effective way to diagnose issues.
.Await()
enables this by making the dispatch synchronous from the callerβs perspective.- This is why itβs important to always await dispatches β it ensures predictable, debuggable code flow.
public partial class Counter : ComponentBase
{
[Inject] private IDispatcher Dispatcher { get; set; }
private async Task Increment()
{
await Dispatcher.Prepare<IncrementCounterAction>()
.With(p => p.Delay, 1)
.Await() // <- Block Execution until all tasks are done.
.DispatchAsync();
}
}