signals

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 15, 2026 License: MIT Imports: 6 Imported by: 1

README

Signals

Type-safe reactive state management for Go, inspired by Angular Signals

Release Go Version Go Report Card GoDoc CI codecov License Stars

A modern, production-grade reactive programming library for Go 1.25+ that brings Angular's powerful signals pattern to the Go ecosystem with full type safety, zero allocations in hot paths, and comprehensive concurrency support.


Features

  • Pure Go - No dependencies, works everywhere Go works
  • Type-Safe - Full generic support with Go 1.25+ type parameters
  • Thread-Safe - Built-in synchronization for concurrent access
  • Zero Allocations - Hot paths designed for zero heap allocations
  • Angular-Inspired - API design inspired by Angular Signals
  • Fine-Grained Reactivity - Only re-compute what changed
  • Glitch-Free - Atomic updates prevent intermediate states
  • Lazy Evaluation - Computed values calculate only when needed
  • Effect Cleanup - Automatic resource management with cleanup callbacks
  • Production Ready - 69 tests, 93.8% coverage, 28 benchmarks

Quick Start

Installation
go get github.com/coregx/signals

Requires Go 1.25 or later.

Basic Usage
package main

import (
    "fmt"
    "github.com/coregx/signals"
)

func main() {
    // Create a reactive signal
    count := signals.New(0)

    // Create computed value with explicit dependencies
    doubled := signals.Computed(func() int {
        return count.Get() * 2
    }, count.AsReadonly())

    // Create effect that runs immediately and on dependency changes
    eff := signals.Effect(func() {
        fmt.Printf("Count: %d, Doubled: %d\n", count.Get(), doubled.Get())
    }, count.AsReadonly(), doubled)
    defer eff.Stop()
    // Output: Count: 0, Doubled: 0

    // Update signal - effect automatically re-runs
    count.Set(5)
    // Output: Count: 5, Doubled: 10

    count.Set(10)
    // Output: Count: 10, Doubled: 20
}
Advanced Example
// Multiple dependencies
firstName := signals.New("John")
lastName := signals.New("Doe")

fullName := signals.Computed(func() string {
    return firstName.Get() + " " + lastName.Get()
}, firstName.AsReadonly(), lastName.AsReadonly())

// Effect with cleanup
eff := signals.EffectWithCleanup(func() func() {
    fmt.Println("Full name:", fullName.Get())

    // Return cleanup function (runs before next execution and on Stop)
    return func() {
        fmt.Println("Effect cleaned up")
    }
}, fullName)

firstName.Set("Jane")  // Cleanup runs, then effect re-runs
lastName.Set("Smith")   // Cleanup runs, then effect re-runs

eff.Stop()  // Final cleanup runs, effect stops

More examples →


Documentation

Reference
Design
  • Angular-Inspired - API design mirrors Angular Signals (see Compatibility table)
  • Explicit Dependencies - Go requires explicit dependency passing (no auto-tracking like Angular)
  • Zero Allocation Hot Paths - Get/Set/Execute paths are allocation-free

Current Status

Version: v0.1.1 (unreleased)

Production Readiness: Core functionality complete and stable!

See detailed roadmap →

Fully Implemented
Phase 1: Core Signal[T]
  • Signal creation and basic operations
  • Thread-safe read/write with RWMutex
  • Subscription system with automatic unsubscribe
  • Context-based auto-cancellation
  • Panic recovery with custom handlers
  • Read-only view support
  • Custom equality functions
  • Comprehensive test coverage (19 tests)
  • Zero allocations in hot paths (verified by benchmarks)
Phase 2: Computed[T]
  • Lazy evaluation with automatic caching
  • Explicit dependency tracking and invalidation
  • Fine-grained reactivity
  • Glitch-free execution
  • Thread-safe recomputation
  • Comprehensive test coverage (17 tests)
  • Optimized performance (minimal allocations)
Phase 3: Effect
  • Explicit dependency tracking
  • Cleanup function support (runs before re-execution and on Stop)
  • Panic recovery with custom handlers
  • Immediate execution on creation (Angular pattern)
  • Comprehensive test coverage (16 tests)
  • Concurrent effect management
Test Coverage
Package Tests Coverage Benchmarks
signals 69 93.8% 28

Key Metrics:

  • 19 Signal tests
  • 17 Computed tests
  • 16 Effect tests
  • 3 Internal tests (type erasure, reflection fallback)
  • 10 Allocation regression tests (testing.AllocsPerRun)
  • 2 Subscribe lifecycle regression tests (goroutine leak, race condition)
  • 2 Readonly cache tests
  • Zero allocations in signal read/write hot paths
  • Race detector clean (all tests pass with -race in CI)
Performance Characteristics
BenchmarkSignal_Get              96773490     13.46 ns/op     0 B/op    0 allocs/op
BenchmarkSignal_Set              20975720     52.42 ns/op     0 B/op    0 allocs/op
BenchmarkSignal_Subscribe        17057289    134   ns/op     52 B/op    2 allocs/op
BenchmarkComputed_Get_Clean      96773490     13.46 ns/op     0 B/op    0 allocs/op
BenchmarkEffect_Execute           8426302    136   ns/op      0 B/op    0 allocs/op
BenchmarkEffect_Create            7097109    324   ns/op    196 B/op    7 allocs/op

Zero allocations in hot paths (Get, Set, Execute). Subscribe uses context.AfterFunc — no goroutine per subscription.

Remaining Work
Phase 4: Documentation (In Progress)
  • User guides and tutorials
  • API documentation examples
  • Migration guides
  • Best practices guide
Phase 5: Advanced Features (Planned v0.2.0)
  • Resource tracking and lifecycle management
  • Advanced batching strategies
  • Performance monitoring and debugging tools
  • Additional utility functions

See ROADMAP.md for detailed timeline.


Development

Requirements
  • Go 1.25 or later
  • golangci-lint (for linting)
  • No external runtime dependencies
Building
# Clone repository
git clone https://github.com/coregx/signals.git
cd signals

# Run all tests
go test -v ./...

# Run with race detector (Linux/macOS, or CI)
go test -v -race ./...

# Run with coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

# Run benchmarks
go test -bench=. -benchmem ./...

# Run linter
golangci-lint run

Contributing

Contributions are welcome! This is an early-stage project and we'd love your help.

Before contributing:

  1. Read CONTRIBUTING.md - Development workflow and guidelines
  2. Check open issues
  3. Review the API Reference

Ways to contribute:

  • Report bugs
  • Suggest features
  • Improve documentation
  • Submit pull requests
  • Star the project

Comparison with Other Libraries

Feature Signals RxGo Reactor
Type-Safe Generics Yes (Go 1.25+) Limited No
Zero Allocations Yes (hot paths) No No
Thread-Safe Yes (built-in) Yes Partial
Angular-Inspired Yes No No
Fine-Grained Reactivity Yes Observable-based Stream-based
Dependencies Zero Multiple Multiple
Learning Curve Low (if you know Angular) Medium Medium

Angular Signals Compatibility

This library is designed to be conceptually compatible with Angular Signals:

Angular Signals Go Signals Status
signal(T) New[T](value) Complete
computed(() => T) Computed[T](fn, deps...) Complete
effect(() => {}) Effect(fn, deps...) Complete
signal.set(value) signal.Set(value) Complete
signal() signal.Get() Complete
signal.update(fn) signal.Update(fn) Complete
signal.asReadonly() signal.AsReadonly() Complete
Automatic tracking Explicit dependencies Adapted for Go
Glitch-free Glitch-free Complete
Lazy computed Lazy computed Complete

License

This project is licensed under the MIT License - see the LICENSE file for details.


Acknowledgments

  • The Angular team for the signals design pattern
  • The Go team for generics and type parameters
  • All contributors to this project

Support


Status: Stable - Production-ready! Version: v0.1.1


Built with care for the Go community

Documentation

Overview

Package signals provides a reactive state management library for Go, inspired by Angular Signals.

Signals are reactive containers that notify subscribers when their values change. This package provides type-safe, thread-safe, and memory-safe primitives for building reactive applications.

Core Types

Signal[T] - A writable reactive value that notifies subscribers on changes.

ReadonlySignal[T] - A read-only view of a signal for encapsulation.

Computed[T] - A derived reactive value that auto-updates when dependencies change.

Effect - Side effects that run when dependencies change.

Example Usage

// Create a writable signal
count := signals.New(0)

// Subscribe to changes
unsub := count.SubscribeForever(func(v int) {
    fmt.Printf("Count changed: %d\n", v)
})
defer unsub()

// Update the signal
count.Set(5)                              // Prints: Count changed: 5
count.Update(func(v int) int { return v + 1 })  // Prints: Count changed: 6

Thread Safety

All operations are thread-safe and protected by sync.RWMutex. The library is designed for concurrent use and includes comprehensive race condition testing.

Memory Safety

All subscriptions return cleanup functions (Unsubscribe) that must be called to prevent memory leaks. Use context.Context for automatic cleanup:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

sig.Subscribe(ctx, func(v int) {
    fmt.Println(v)
})
// Automatically unsubscribes after 5 seconds

Encapsulation Pattern

Use AsReadonly() to expose signals while keeping mutations controlled:

type CounterService struct {
    count Signal[int]  // private
}

func (s *CounterService) Count() ReadonlySignal[int] {
    return s.count.AsReadonly()  // expose read-only
}

func (s *CounterService) Increment() {
    s.count.Update(func(n int) int { return n + 1 })
}

Performance

Signal operations are highly optimized:

  • Get(): < 15ns/op (read-locked)
  • Set(): < 200ns/op (with notification)
  • Subscribe/Unsubscribe: O(1) using map-based storage

Design Principles

1. Type Safety - 100% generic, no interface{} or type assertions 2. Thread Safety - All operations protected with proper locking 3. Memory Safety - Explicit cleanup with Unsubscribe functions 4. Panic Safety - All callbacks execute with panic recovery 5. Context Awareness - Standard Go context.Context integration

For detailed documentation, see: https://github.com/coregx/signals

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type EffectOptions

type EffectOptions struct {
	// OnPanic is called when the effect or cleanup function panics.
	// If nil, panics are logged to stderr.
	OnPanic func(err any, stack []byte)
}

EffectOptions configures effect behavior.

type EffectRef

type EffectRef interface {
	// Stop stops the effect and runs final cleanup.
	// After calling Stop, the effect will no longer run.
	// Safe to call multiple times.
	Stop()
}

EffectRef represents a running side effect that can be stopped.

Effects run immediately upon creation (Angular pattern) and re-run when dependencies change. They support cleanup functions that run before the next effect and when stopped.

Use the Stop() method to clean up the effect when no longer needed.

func Effect

func Effect(fn func(), deps ...any) EffectRef

Effect creates an effect that runs immediately and on dependency changes.

CRITICAL: The effect function runs IMMEDIATELY upon creation, then again whenever any dependency changes. This matches Angular's effect() behavior.

Dependencies must be explicitly passed as additional arguments. Each dependency can be a ReadonlySignal of any type.

Example:

count := signals.New(0)
name := signals.New("Alice")

// Effect runs immediately (prints "Alice: 0")
// Then runs again when count or name changes
eff := signals.Effect(
    func() {
        fmt.Printf("%s: %d\n", name.Get(), count.Get())
    },
    count.AsReadonly(),
    name.AsReadonly(),
)
defer eff.Stop()

count.Set(5)  // Effect runs again (prints "Alice: 5")
name.Set("Bob")  // Effect runs again (prints "Bob: 5")

For effects that need cleanup, use EffectWithCleanup instead.

func EffectWithCleanup

func EffectWithCleanup(fn func() func(), deps ...any) EffectRef

EffectWithCleanup creates an effect with cleanup callback support.

The effect function returns a cleanup function that will be called:

  • Before the next effect execution
  • When Stop() is called

This is useful for:

  • Canceling timers or intervals
  • Closing connections or file handles
  • Removing event listeners
  • Aborting pending operations

Example:

count := signals.New(0)

eff := signals.EffectWithCleanup(
    func() func() {
        // Effect: start timer
        ticker := time.NewTicker(time.Second)
        go func() {
            for range ticker.C {
                fmt.Println("Tick:", count.Get())
            }
        }()

        // Cleanup: stop timer
        return func() {
            ticker.Stop()
        }
    },
    count.AsReadonly(),
)
defer eff.Stop()  // Runs cleanup

count.Set(5)  // Old cleanup runs, new effect starts, new cleanup registered

func EffectWithOptions

func EffectWithOptions(fn func() func(), opts EffectOptions, deps ...any) EffectRef

EffectWithOptions creates an effect with custom options.

Use this when you need custom panic handling for effects or cleanup functions.

Example:

count := signals.New(0)
eff := signals.EffectWithOptions(
    func() func() {
        fmt.Println("Effect:", count.Get())
        return nil
    },
    signals.EffectOptions{
        OnPanic: func(err any, stack []byte) {
            metrics.IncrementEffectPanic()
        },
    },
    count.AsReadonly(),
)

type EqualFunc

type EqualFunc[T any] func(a, b T) bool

EqualFunc is a function that compares two values for equality. It returns true if the values are considered equal, false otherwise.

Use custom equality functions when you need:

  • Value-based comparison for complex types
  • Comparison by specific fields (e.g., ID only)
  • Custom business logic for equality

Example:

type User struct {
    ID   int
    Name string
}

// Compare users by ID only
userSignal := signals.NewWithOptions(&User{ID: 1, Name: "Alice"}, signals.Options[*User]{
    Equal: func(a, b *User) bool {
        if a == nil || b == nil {
            return a == b
        }
        return a.ID == b.ID
    },
})

type Options

type Options[T any] struct {
	// Equal is an optional custom equality function.
	// If nil, signals will not perform equality checks and always notify on Set().
	//
	// Angular Signals use Object.is() by default (referential equality).
	// For Go, we allow optional equality checks since not all types are comparable.
	Equal EqualFunc[T]

	// OnPanic is an optional custom panic handler for subscriber callbacks.
	// If nil, panics are logged to stderr and execution continues.
	//
	// This handler is called when a subscriber panics, allowing custom logging,
	// metrics, or error recovery strategies.
	//
	// Example:
	//   OnPanic: func(err any, stack []byte) {
	//       log.Printf("Subscriber panic: %v\n%s", err, stack)
	//       metrics.IncrementPanicCounter()
	//   }
	OnPanic func(err any, stack []byte)
}

Options configures the behavior of a Signal.

type ReadonlySignal

type ReadonlySignal[T any] interface {
	// Get returns the current value of the signal.
	Get() T

	// Subscribe registers a callback to be notified when the signal's value changes.
	Subscribe(ctx context.Context, fn func(T)) Unsubscribe

	// SubscribeForever registers a callback that will never be automatically canceled.
	SubscribeForever(fn func(T)) Unsubscribe
}

ReadonlySignal is a read-only view of a Signal.

Use this type for encapsulation - expose ReadonlySignal while keeping the writable Signal private. This prevents external code from modifying the signal's value.

This pattern is inspired by Angular Signals' asReadonly() method.

Example:

type CounterService struct {
    counter Signal[int]  // private writable signal
}

func (s *CounterService) Counter() ReadonlySignal[int] {
    return s.counter.AsReadonly()  // expose as read-only
}

func (s *CounterService) Increment() {
    s.counter.Update(func(n int) int { return n + 1 })
}

func Computed

func Computed[T any](compute func() T, deps ...any) ReadonlySignal[T]

Computed creates a read-only signal that derives its value from a computation function.

IMPORTANT: The compute function MUST be pure - it should only read signals and compute a result without side effects (no logging, no mutations, no I/O).

Dependencies must be explicitly passed as additional arguments. Each dependency can be a ReadonlySignal of any type. When any dependency changes, this computed signal is marked dirty and will recompute on the next Get().

The computed signal uses lazy evaluation and memoization:

  • Only computes when accessed (Get)
  • Caches result until marked dirty
  • Uses atomic operations for lock-free dirty checks

Example:

firstName := signals.New("John")
lastName := signals.New("Doe")

// Dependencies are explicit - pass signals after compute function
fullName := signals.Computed(
    func() string {
        return firstName.Get() + " " + lastName.Get()
    },
    firstName.AsReadonly(),
    lastName.AsReadonly(),
)

fmt.Println(fullName.Get())  // "John Doe"
firstName.Set("Jane")
// Computed is marked dirty, will recompute on next Get()
fmt.Println(fullName.Get())  // "Jane Doe"

Dependencies can be of different types:

count := signals.New(5)
name := signals.New("items")

message := signals.Computed(
    func() string {
        return fmt.Sprintf("%d %s", count.Get(), name.Get())
    },
    count.AsReadonly(),  // ReadonlySignal[int]
    name.AsReadonly(),   // ReadonlySignal[string]
)

func ComputedWithOptions

func ComputedWithOptions[T any](compute func() T, opts Options[T], deps ...any) ReadonlySignal[T]

ComputedWithOptions creates a computed signal with custom options.

Use this when you need custom panic handling for the compute function or subscribers.

Example:

count := signals.New(5)
comp := signals.ComputedWithOptions(
    func() int { return count.Get() * 2 },
    signals.Options[int]{
        OnPanic: func(err any, stack []byte) {
            metrics.IncrementComputedPanic()
        },
    },
    count.AsReadonly(),
)

type Signal

type Signal[T any] interface {
	// Get returns the current value of the signal.
	// This operation is thread-safe and uses a read lock.
	Get() T

	// Set replaces the signal's value with a new value.
	// If a custom Equal function is provided, the signal will only notify
	// subscribers if the new value is different from the old value.
	//
	// All subscribers are notified after the value is updated.
	Set(value T)

	// Update transforms the signal's value using the provided function.
	// The function receives the current value and returns the new value.
	//
	// This operation locks the signal for the duration of the transform function,
	// so keep the function fast. After the transform, Set() is called with the
	// new value (triggering equality checks and notifications).
	//
	// Example:
	//   count.Update(func(v int) int { return v + 1 })
	Update(fn func(T) T)

	// AsReadonly returns a read-only view of this signal.
	// Use this for encapsulation - keep the Signal private, expose ReadonlySignal.
	//
	// This follows the Angular Signals pattern of controlled mutations.
	//
	// Example:
	//   type Service struct {
	//       count Signal[int]  // private
	//   }
	//
	//   func (s *Service) Count() ReadonlySignal[int] {
	//       return s.count.AsReadonly()
	//   }
	AsReadonly() ReadonlySignal[T]

	// Subscribe registers a callback to be notified when the signal's value changes.
	// The callback receives the new value.
	//
	// The subscription is automatically canceled when the context is done.
	// This allows automatic cleanup on timeout, cancellation, or deadline.
	//
	// Returns an Unsubscribe function for manual cleanup.
	//
	// Example:
	//   ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	//   defer cancel()
	//
	//   unsub := sig.Subscribe(ctx, func(v int) {
	//       fmt.Println("Value:", v)
	//   })
	//   defer unsub()  // Manual cleanup (happens before context timeout)
	Subscribe(ctx context.Context, fn func(T)) Unsubscribe

	// SubscribeForever registers a callback that will never be automatically canceled.
	// Equivalent to Subscribe(context.Background(), fn).
	//
	// IMPORTANT: You MUST call the returned Unsubscribe function to prevent memory leaks.
	//
	// Example:
	//   unsub := sig.SubscribeForever(func(v int) {
	//       fmt.Println(v)
	//   })
	//   defer unsub()  // REQUIRED for cleanup
	SubscribeForever(fn func(T)) Unsubscribe
}

Signal is a writable reactive container for a value of type T.

Signals notify subscribers when their value changes (via Set or Update). All operations are thread-safe and can be called from multiple goroutines.

Example:

count := signals.New(0)
count.Set(5)
value := count.Get()  // 5
count.Update(func(v int) int { return v + 1 })  // Now 6

func New

func New[T any](initial T) Signal[T]

New creates a new writable signal with the given initial value.

The signal uses default behavior:

  • No equality checks (always notifies on Set)
  • Default panic handling (log and continue)

Example:

count := signals.New(0)
count.Set(5)
fmt.Println(count.Get())  // 5

func NewWithOptions

func NewWithOptions[T any](initial T, opts Options[T]) Signal[T]

NewWithOptions creates a new writable signal with custom options.

Use this when you need:

  • Custom equality checks (opts.Equal)
  • Custom panic handling (opts.OnPanic)

Example:

// Compare slices by content, not by pointer
data := signals.NewWithOptions([]int{1, 2, 3}, signals.Options[[]int]{
    Equal: func(a, b []int) bool {
        return slices.Equal(a, b)
    },
})

type Unsubscribe

type Unsubscribe func()

Unsubscribe is a function that removes a subscription. Call it to stop receiving notifications and prevent memory leaks.

Example:

unsub := signal.Subscribe(ctx, func(v int) {
    fmt.Println(v)
})
defer unsub()  // Cleanup

Directories

Path Synopsis
cmd
example command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL