DKX.Signals
1.0.0-rc.11
Prefix Reserved
dotnet add package DKX.Signals --version 1.0.0-rc.11
NuGet\Install-Package DKX.Signals -Version 1.0.0-rc.11
<PackageReference Include="DKX.Signals" Version="1.0.0-rc.11" />
<PackageVersion Include="DKX.Signals" Version="1.0.0-rc.11" />
<PackageReference Include="DKX.Signals" />
paket add DKX.Signals --version 1.0.0-rc.11
#r "nuget: DKX.Signals, 1.0.0-rc.11"
#:package DKX.Signals@1.0.0-rc.11
#addin nuget:?package=DKX.Signals&version=1.0.0-rc.11&prerelease
#tool nuget:?package=DKX.Signals&version=1.0.0-rc.11&prerelease
DKX.Signals 
<a href="https://www.buymeacoffee.com/david_kudera" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-violet.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
Reactive signals for C# inspired by Angular signals and @preact/signals.
Installation
dotnet add package DKX.Signals
Example
using DKX.Signals;
var scope = ScopeBuilder.Default.Build();
var a = scope.Signal(1);
var b = scope.Signal(2);
var result = scope.Computed(new { a, b }, static deps => deps.a.Get() + deps.b.Get());
using var changed = scope.Effect(
new { result },
deps =>
{
Console.WriteLine($"Result: {deps.result.Get()}");
}
);
Scopes
Scopes are the main building blocks of the signal's system. They are used to create signals, computed values, effects and others.
For the best performance, you should create a scope per component or group of components when creating a store.
Avoid creating static scopes shared across the app and users. This can lead to unnecessary re-renders and performance issues.
using DKX.Signals;
var scope = ScopeBuilder.Default.Build();
Writable Signals
With scope ready, you can create first writable signals. These signals are the top-level signals which other signals depend on.
var a = scope.Signal(1); // Create a writable signal of type int with initial value 1
Console.WriteLine(a.Get()); // output: 1
// Now let's update the signal
a.Set(2);
Console.WriteLine(a.Get()); // output: 2
// Or modify the previous value
a.Update(x => x * 2);
Console.WriteLine(a.Get()); // output: 4
Computed Signals
Computed signals are derived from other signals. They are both lazily evaluated and memoized.
This means that they will only be re-evaluated when you access them using the Get()
method or an effect depends on them.
When creating a signal, you have to pass an object with the signals you want to depend on.
var a = scope.Signal(1);
var b = scope.Signal(2);
var result = scope.Computed(new { a, b }, static deps => deps.a.Get() + deps.b.Get());
// At this time the result is not evaluated yet
Console.WriteLine(result.Get()); // output: 3
// The result is evaluated now
Console.WriteLine(result.Get()); // output: 3
// The result is not evaluated again, previous value is returned because non of the input signals changed
Effects
Effects are used to perform side effects when a signal changes. You can use them when you need to observe some signals and perform some action when they change. They are automatically called when they are created. That means they are always called at least once.
Use the dispose pattern when creating effects.
var a = scope.Signal(1);
var b = scope.Signal(2);
var result = scope.Computed(new { a, b }, static deps => deps.a.Get() + deps.b.Get());
using var changed = scope.Effect(
new { result },
static deps =>
{
Console.WriteLine($"Result: {deps.result.Get()}");
}
);
// Effect called for the first time - output: Result: 3
a.Set(2);
// Effect called again - output: Result: 4
Effect cleanup
When creating an effect, you can return a cleanup function. This function is called in two cases:
- When the effect is disposed
- Just before the effect is called again so you can do some cleanup from the previous run
using var changed = scope.Effect(
new { result },
static deps =>
{
Console.WriteLine($"Result: {deps.result.Get()}");
return () =>
{
Console.WriteLine("Cleanup");
};
}
);
Resource
Resource is like a combination of an async computed signal and an effect. It is used to perform async operations like fetching data from an API.
Just like effects, resources are automatically called when they are created. They are always called at least once.
When creating a resource signal, you have to pass three parameters:
- An object with the signals you want to depend on.
- A function that takes the dependencies and returns a request or null.
- An async function that takes the request and a cancellation token and returns the result.
var userId = scope.Signal(42);
var userResource = scope.Resource(
new { userId },
static deps =>
{
return deps.userId.Get();
},
static async (req, cancellationToken) =>
{
return await FetchUser(req, cancellationToken);
}
);
Signals that depend on a resource will be updated only after that resource is resolved. They will stay idle until then and return their default values.
Disable processing
You can disable the processing of a resource by returning null from the request function.
var userId = scope.Signal<int?>(null);
var userResource = scope.Resource(
new { userId },
static deps =>
{
// deps.userId.Get() might be null here
return deps.userId.Get();
},
static async (req, cancellationToken) =>
{
// This function will not be called if the request is null
return await FetchUser(req, cancellationToken);
}
);
Reloading
You can reload a resource by calling the Reload
method. This will call the request function again and update the result.
var userId = scope.Signal(42);
var userResource = scope.Resource(
new { userId },
static deps =>
{
return deps.userId.Get();
},
static async (req, cancellationToken) =>
{
return await FetchUser(req, cancellationToken);
}
);
// ...
userResource.Reload();
Linked signal
Linked signals are used to create a signal linked to another signal. They are like a combination of a computed signal and a writable signal.
ISignal<ShippingMethod[]> shippingOptions = GetShippingOptions();
var selectedOption = scope.LinkedSignal(
shippingOptions,
static (shippingOptions, previous) =>
{
return shippingOptions[0];
}
);
// ...
selectedOption.Set(shippingOptions[1]);
Here is another example that shows what happens when the linked signal is manually updated.
var shippingOptions = scope.Signal(new string[] { "Ground", "Air", "Sea" });
var selectedOption = scope.LinkedSignal(
shippingOptions,
static (shippingOptions, previous) => shippingOptions[0]
);
Console.WriteLine(selectedOption.Get()); // output: Ground
selectedOption.Set(shippingOptions.Get()[2]);
Console.WriteLine(selectedOption.Get()); // output: Sea
shippingOptions.Set(new string[] { "Email", "Will Call", "Postal Service" });
Console.WriteLine(selectedOption.Get()); // output: Email
Batch
Batching is used to group multiple updates into a single update. This is useful when you want to update multiple signals at once and avoid multiple re-renders. That applies to all effects too.
scope.Batch(() =>
{
a.Set(2);
b.Set(3);
});
// Dependent effects are called only once
Return value
You can return a value from the batch function. This value will be returned from the Batch
method.
var result = scope.Batch(() =>
{
a.Set(2);
b.Set(3);
return 42;
});
Console.WriteLine(result); // output: 42
Store
Store is an ordinary class that implements the IStore
interface. It is primarily used when you want to share a complex state between multiple components.
Each store implementing IStore
must create its own scope and expose it in Scope
property.
using DKX.Signals;
public sealed class MyStore : IStore
{
private readonly IWritableSignal<int> _counter;
public MyStore()
{
Scope = ScopeBuilder.Default.Build();
_counter = Scope.Signal(0);
}
public IScope Scope { get; }
public ISignal<int> Counter => _counter;
public void Increment()
{
_counter.Update(x => x + 1);
}
}
Logging
To enable logging, you need to obtain the ILoggerFactory
instance and pass it to the ScopeBuilder
.
var myCustomScopeBuilder = ScopeBuilder.Default.WithLoggerFactory(loggerFactory);
Extensions
There are few extension methods that are commonly used.
IWritableSignal<bool>.Toggle()
This method is used to toggle the value of a boolean signal.
var a = scope.Signal(true); // value: true
a.Toggle(); // value: false
a.Toggle(); // value: true
IWritableSignal<bool>.Toggle(bool from)
Used to toggle the value of a boolean signal but only if the current value is same as the from
value.
var a = scope.Signal(true); // value: true
a.Toggle(false); // value: true
a.Toggle(true); // value: false
IWritableSignal<T>.Increment() where T : INumber<T>
Increment any INumber
signal by 1.
var a = scope.Signal(1); // value: 1
a.Increment(); // value: 2
IWritableSignal<T>.Decrement() where T : INumber<T>
Decrement any INumber
signal by 1.
var a = scope.Signal(1); // value: 1
a.Decrement(); // value: 0
IWritableSignal<T>.Decrement(T min) where T : INumber<T>
Decrement any INumber
signal by 1 but only if the current value is greater than the min
value.
var a = scope.Signal(2); // value: 2
a.Decrement(0); // value: 1
a.Decrement(0); // value: 0
a.Decrement(0); // value: 0
Mermaid diagrams
For debugging purposes, you can create a mermaid diagram of the signals' graph. This is useful to see how the signals are connected and what the dependencies are.
In this example, we also assign custom debug names to the signals.
using DKX.Signals;
var scope = ScopeBuilder.Default.Build();
using var mermaid = scope.CreateMermaidDiagramBuilder(simplified: true);
var a = scope.Signal(1, new SignalConfiguration<int> { DebugName = "a" });
var b = scope.Signal(2, new SignalConfiguration<int> { DebugName = "b" });
var result = scope.Computed(
new { a, b },
static deps => deps.a.Get() + deps.b.Get(),
new SignalConfiguration<int> { DebugName = "result" }
);
// Take a first snapshot of the current state
mermaid.TakeSnapshot("Initial");
using var changed = scope.Effect(
new { result },
deps =>
{
Console.WriteLine($"Result: {deps.result.Get()}");
},
new SignalConfiguration { DebugName = "effect" }
);
// Let's see how adding an effect changes the graph
mermaid.TakeSnapshot("After effect created");
a.Set(2);
// Now let's see how changing a signal changes the graph
mermaid.TakeSnapshot("After signal a changed");
// And finally build the whole graph with all changes in time
var graph = mermaid.Build();
Now graph
will contain the following mermaid diagram:
flowchart TB
classDef dirty fill:#FFEBEE,stroke:#F44336,stroke-width:2px,color:black
classDef outdated fill:#FFF8E1,stroke:#FFA000,stroke-dasharray: 5 5,stroke-width:2px,color:black
subgraph 0_snapshot["Initial"]
direction LR
1_1["✏️ <b><u>a</u> ver. 0</b><br>Value: 1"]
1_2["✏️ <b><u>b</u> ver. 0</b><br>Value: 2"]
1_3{{"🤖 <b><u>result</u> ver. 0</b><br>Value: <i style="color: gray;">no value</i><br>Seen global version: 0"}}
1_1 -- ⬅️ ver. 0 --> 1_3
1_2 -- ⬅️ ver. 0 --> 1_3
class 1_3 dirty
end
subgraph 1_snapshot["After effect created"]
direction LR
2_1["✏️ <b><u>a</u> ver. 0</b><br>Value: 1"]
2_2["✏️ <b><u>b</u> ver. 0</b><br>Value: 2"]
2_3{{"🤖 <b><u>result</u> ver. 1</b><br>Value: 3<br>Seen global version: 0"}}
2_4(["👁️ <b><u>effect</u></b>"])
2_1 -- ⬅️ ver. 0 --> 2_3
2_2 -- ⬅️ ver. 0 --> 2_3
2_3 -- ⬅️ ver. 1 --> 2_4
end
subgraph 2_snapshot["After signal a changed"]
direction LR
3_1["✏️ <b><u>a</u> ver. 1</b><br>Value: 2"]
3_2["✏️ <b><u>b</u> ver. 0</b><br>Value: 2"]
3_3{{"🤖 <b><u>result</u> ver. 2</b><br>Value: 4<br>Seen global version: 1"}}
3_4(["👁️ <b><u>effect</u></b>"])
3_1 -- ⬅️ ver. 1 --> 3_3
3_2 -- ⬅️ ver. 0 --> 3_3
3_3 -- ⬅️ ver. 2 --> 3_4
end
0_snapshot --> 1_snapshot
1_snapshot --> 2_snapshot
Please note that this is a simplified version of the graph. The full graph uses animations and shows more details.
Internals
Inspired by Angular
This project is heavily inspired by Angular signals even down to the implementation. That means you can read their documentation to understand how it works.
We use Angular tests to verify our implementation. There are only a few tests that could not be ported because they don't make sense in C#.
There are a few differences in the implementation. Here are the most important ones:
Scoping
In Angular, there are no scopes and signals lifecycle is hard to predict.
Live vs non-live nodes
To avoid memory leaks, Angular uses live and non-live nodes. Because we use scopes and signals are tied to specific component lifecycle, we don't need to make this distinction. This makes the implementation simpler and easier to understand. All we have are live nodes.
Epoch
In Angular, the epoch is a global version of the signals. When you update a signal, the epoch is incremented and signals can use that to determine if they are outdated. Angular epoch is global and is shared across all signals. In our implementation, we use "global version" which is shared across all signals in the same scope.
Dependency tracking
Angular tracks dependencies dynamically by observing the signals' scope. In our implementation, we use a static approach. This is much easier to implement and understand and is also a "native" way of avoiding cyclic dependencies.
Inspired by @preact/signals
You can check out preact documentation here: https://preactjs.com/guide/v10/signals.
We implemented batch
in the same way as preact. We also use all their tests to verify our implementation.
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net9.0
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on DKX.Signals:
Package | Downloads |
---|---|
DKX.Signals.Blazor
Integration of DKX.Signals into Blazor |
GitHub repositories
This package is not used by any popular GitHub repositories.
Version | Downloads | Last Updated |
---|---|---|
1.0.0-rc.11 | 140 | 5/8/2025 |
1.0.0-rc.10 | 123 | 5/8/2025 |
1.0.0-rc.9 | 123 | 5/7/2025 |
1.0.0-rc.8 | 128 | 5/7/2025 |
1.0.0-rc.7 | 121 | 5/6/2025 |
1.0.0-rc.6 | 136 | 5/4/2025 |
1.0.0-rc.5 | 54 | 5/3/2025 |
1.0.0-rc.4 | 61 | 5/3/2025 |
1.0.0-rc.3 | 52 | 5/3/2025 |
1.0.0-rc.2 | 88 | 5/2/2025 |
1.0.0-rc.1 | 131 | 5/1/2025 |