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