DKX.Signals 1.0.0-rc.7

Prefix Reserved
This is a prerelease version of DKX.Signals.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package DKX.Signals --version 1.0.0-rc.7
                    
NuGet\Install-Package DKX.Signals -Version 1.0.0-rc.7
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DKX.Signals" Version="1.0.0-rc.7" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DKX.Signals" Version="1.0.0-rc.7" />
                    
Directory.Packages.props
<PackageReference Include="DKX.Signals" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DKX.Signals --version 1.0.0-rc.7
                    
#r "nuget: DKX.Signals, 1.0.0-rc.7"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DKX.Signals@1.0.0-rc.7
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DKX.Signals&version=1.0.0-rc.7&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=DKX.Signals&version=1.0.0-rc.7&prerelease
                    
Install as a Cake Tool

DKX.Signals NuGet Version

<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:

  1. When the effect is disposed
  2. 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:

  1. An object with the signals you want to depend on.
  2. A function that takes the dependencies and returns a request or null.
  3. 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);
    }
);

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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