Here's the updated README.md with the StreamSignal feature added:
A Solid.js-inspired reactive library for Go applications
Work in progress
- Introduction
- Installation
- Core Concepts
- Usage Examples
- Concurrency & Safety
- Advanced Features
- API Reference
- Best Practices
Firm-Go is a reactive state management library for Go applications inspired by Solid.js. It enables building applications with fine-grained reactivity, automatic dependency tracking, and efficient update propagation - all while maintaining Go's type safety and concurrency model.
- âś… Fine-grained reactivity: Updates propagate efficiently through a dependency graph
- âś… Type-safe: Built with Go generics for compile-time type checking
- âś… Automatic cleanup: Resources are automatically cleaned up when no longer needed
- âś… Batched updates: Efficiently group related state changes
- âś… Async support: First-class support for asynchronous operations with WaitGroups
- âś… Reactive primitives: Signals, Effects, Computed, Context, Resources and more
- âś… Streaming data: Create signals from continuous data sources like CLI output, websockets, or events
go get github.com/davidroman0O/firm-go
Firm-Go uses the concept of an "Owner" to manage the lifecycle of reactive primitives. The Root
function creates a new owner and provides a way to safely wait for async operations:
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
// Create signals, effects, etc. owned by this owner
// Optional cleanup to run when root is disposed
return func() {
fmt.Println("Root disposed")
}
})
// Wait for all async operations to complete
wait()
// Later, clean up all resources
defer cleanup()
Signals are the foundation of Firm-Go's reactivity. They hold values that can change over time:
// Create a signal with an initial value
count := firm.Signal(owner, 0)
// Read the current value (tracks as dependency)
value := count.Get()
// Read without tracking
value := count.Peek()
// Update the value
count.Set(5)
// Update based on current value
count.Update(func(current int) int {
return current + 1
})
Effects run side effects when their dependencies change:
// Effect with automatic dependency tracking
firm.Effect(owner, func() firm.CleanUp {
fmt.Println("Count is now:", count.Get())
// Return an optional cleanup function
return func() {
fmt.Println("Cleaning up after effect")
}
}, nil) // nil means auto-track dependencies
// Effect with explicit dependencies
firm.Effect(owner, func() firm.CleanUp {
fmt.Println("Count is now:", count.Get())
return nil
}, []firm.Reactive{count})
Computed values are derived from other reactive values:
// Create a memo (computed value) from other signals
count := firm.Signal(owner, 5)
// Memo with automatic dependency tracking (nil)
doubled := firm.Memo(owner, func() int {
return count.Get() * 2
}, nil) // nil means auto-track dependencies
// Read the computed value
fmt.Println("Doubled:", doubled.Get())
Contexts provide a way to pass values down through a reactive system:
// Create a context with a default value
themeContext := firm.NewContext(owner, "light")
// In a child component:
firm.Effect(owner, func() firm.CleanUp {
// Get the current theme
theme := themeContext.Use()
fmt.Println("Current theme:", theme)
return nil
}, nil)
// Update the context
themeContext.Set("dark")
// Conditional rendering based on context
themeContext.Match(owner, "dark", func(childOwner *firm.Owner) firm.CleanUp {
// This runs only when theme is "dark"
return nil
})
Resources handle asynchronous operations with built-in loading and error states:
// Create a resource with an async fetcher
userResource := firm.Resource(owner, func() (User, error) {
// Simulate API call
time.Sleep(100 * time.Millisecond)
return User{Name: "John", Age: 30}, nil
})
// Check loading state
firm.Effect(owner, func() firm.CleanUp {
if userResource.Loading() {
fmt.Println("Loading user...")
} else if err := userResource.Error(); err != nil {
fmt.Println("Error loading user:", err)
} else {
user := userResource.Data()
fmt.Println("User loaded:", user.Name)
}
return nil
}, []firm.Reactive{userResource})
// Refresh data
userResource.Refetch()
Create signals that update from continuous data sources like CLI output, WebSockets, or events:
// Create a signal from a continuous data source
output := firm.StreamSignal(owner, "", func(set func(string), done func()) {
// Start a command or open a connection
cmd := exec.Command("ping", "-c", "5", "example.com")
stdout, _ := cmd.StdoutPipe()
cmd.Start()
// Read and update the signal with each line
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
set(scanner.Text())
}
// Mark as done when finished
cmd.Wait()
done()
})
// Use the streaming data like any other signal
firm.Effect(owner, func() firm.CleanUp {
line := output.Get()
fmt.Println("Output:", line)
return nil
}, []firm.Reactive{output})
Batch multiple updates to prevent cascading rerenders:
firm.Batch(owner, func() {
// These updates will be batched together
firstName.Set("John")
lastName.Set("Doe")
age.Set(30)
// Effects will only run once after the batch completes
})
Firm-Go provides a robust way to track and wait for asynchronous operations:
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
// Track a pending operation
owner.TrackPendingOp()
go func() {
// Do some async work
time.Sleep(500 * time.Millisecond)
// Signal completion
owner.CompletePendingOp()
}()
return nil
})
// Wait for all async operations to complete
wait()
// Clean up
cleanup()
Create values that automatically update on an interval:
// Create a polling signal that updates every second
timePolling := firm.NewPolling(owner, func() time.Time {
return time.Now()
}, time.Second)
// Use the polling value
firm.Effect(owner, func() firm.CleanUp {
fmt.Println("Current time:", timePolling.Get().Format(time.RFC3339))
return nil
}, []firm.Reactive{timePolling})
// Control the polling
timePolling.Pause() // Stop polling
timePolling.Resume() // Resume polling
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
count := firm.Signal(owner, 0)
doubled := firm.Memo(owner, func() int {
return count.Get() * 2
}, nil) // auto-tracking on ANY change - you should use `[]firm.Reactive{count}`
firm.Effect(owner, func() firm.CleanUp {
fmt.Printf("Count: %d, Doubled: %d\n", count.Get(), doubled.Get())
return nil
}, nil)
// Simulate updates
count.Set(1) // Logs: Count: 1, Doubled: 2
count.Set(2) // Logs: Count: 2, Doubled: 4
return nil
})
wait()
defer cleanup()
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
userId := firm.Signal(owner, 1)
userResource := firm.Resource(owner, func() (User, error) {
id := userId.Get()
return fetchUserById(id) // Your API function
})
firm.Effect(owner, func() firm.CleanUp {
if userResource.Loading() {
fmt.Println("Loading user...")
} else if err := userResource.Error(); err != nil {
fmt.Println("Error:", err)
} else {
user := userResource.Data()
fmt.Println("User:", user.Name)
}
return nil
}, []firm.Reactive{userResource})
// Change user ID to trigger a new fetch
userId.Set(2)
return nil
})
// Wait for all async operations (including fetches)
wait()
defer cleanup()
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
// Create a signal from a WebSocket connection
messages := firm.StreamSignal(owner, "", func(set func(string), done func()) {
conn, _, err := websocket.DefaultDialer.Dial("ws://example.com/socket", nil)
if err != nil {
fmt.Println("Error connecting:", err)
done()
return
}
defer conn.Close()
// Read messages until connection closes
for {
_, message, err := conn.ReadMessage()
if err != nil {
fmt.Println("Error reading:", err)
break
}
// Update signal with each new message
set(string(message))
}
done()
})
// Process incoming messages
firm.Effect(owner, func() firm.CleanUp {
msg := messages.Get()
if msg != "" {
fmt.Println("Received message:", msg)
// Process message here
}
return nil
}, []firm.Reactive{message})
return nil
})
// Wait for stream to complete
wait()
defer cleanup()
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
search := firm.Signal(owner, "")
// Debounced search query - updates 300ms after the source
debouncedSearch := firm.Defer(owner, search, 300)
firm.Effect(owner, func() firm.CleanUp {
// Only runs when the debounced value changes
query := debouncedSearch.Get()
if query != "" {
fmt.Println("Searching for:", query)
// performSearch(query)
}
return nil
}, nil)
// These rapid updates only result in one search
search.Set("a")
search.Set("ap")
search.Set("app")
search.Set("appl")
search.Set("apple")
return nil
})
// Wait for debounced operations to complete
wait()
defer cleanup()
Firm-Go is designed for concurrent Go applications with safety built-in:
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
count := firm.Signal(owner, 0)
// Launch multiple goroutines updating the signal
for i := 0; i < 10; i++ {
owner.TrackPendingOp() // Track each goroutine
go func(idx int) {
defer owner.CompletePendingOp() // Signal completion
// Atomic update of signal
count.Update(func(v int) int {
return v + 1
})
fmt.Printf("Goroutine %d updated count\n", idx)
}(i)
}
return func() {
fmt.Println("Final count:", count.Get()) // Should be 10
}
})
// Wait for all goroutines to complete
wait()
defer cleanup()
Create signals that derive from others with two-way binding:
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
user := firm.Signal(owner, User{Name: "John", Age: 30})
// Create a derived signal for the name field
nameSignal := firm.DerivedSignal(
owner,
user,
// Getter
func(u User) string {
return u.Name
},
// Setter
func(u User, name string) User {
u.Name = name
return u
},
)
// Now nameSignal can be used as a regular signal
fmt.Println("Name:", nameSignal.Get())
// Update via the derived signal
nameSignal.Set("Jane")
// The original user signal is also updated
fmt.Println("User:", user.Get().Name) // "Jane"
return nil
})
wait()
defer cleanup()
Sometimes you need to read signals without creating dependencies:
firm.Effect(owner, func() firm.CleanUp {
// This creates a dependency
count := counter.Get()
// This does not create a dependency
config := firm.Untrack(owner, func() Config {
return configSignal.Get()
})
fmt.Printf("Count: %d, Config: %v\n", count, config)
return nil
}, nil)
Signal[T](owner, initialValue) -> *signalImpl[T]
Methods:
Get() -> T // Get with dependency tracking
Peek() -> T // Get without tracking
Set(value T) // Set a new value
Update(fn func(T) T) // Update functionally
Effect(owner, fn func() CleanUp, deps []Reactive)
Memo[T](owner, compute func() T, deps []Reactive) -> *signalImpl[T]
NewContext[T](owner, defaultValue) -> *Context[T]
Methods:
Use() -> T // Get context value with tracking
Set(value T) // Update context value
Match(owner, value, fn) -> CleanUp // Run when value matches exactly
When(owner, matcher, fn) -> CleanUp // Run when matcher returns true
Resource[T](owner, fetcher func() (T, error)) -> *resourceImpl[T]
Methods:
Loading() -> bool // Check if loading
Data() -> T // Get data
Error() -> error // Get error
Refetch() // Fetch again
OnLoad(fn func(T, error)) // Run when load completes
StreamSignal[T](owner, initialValue T, setup func(set func(T), done func())) -> *signalImpl[T]
Root(fn func(owner *Owner) CleanUp) -> (cleanup CleanUp, wait func())
Owner Methods:
TrackPendingOp() // Track an async operation
CompletePendingOp() // Signal completion of an async operation
WaitForPending() // Wait for all tracked operations to complete
Always use the wait()
function to ensure all async operations complete:
cleanup, wait := firm.Root(func(owner *firm.Owner) firm.CleanUp {
// Your reactive code with async operations
return nil
})
// Wait for all async operations to complete
wait()
// Then clean up
defer cleanup()
For every call to TrackPendingOp()
, ensure there's a matching CompletePendingOp()
:
owner.TrackPendingOp()
go func() {
defer owner.CompletePendingOp() // Always call this, even on error paths
// Your async code
}()
When using resources or sharing state across goroutines, use mutexes:
var mu sync.Mutex
count := 0
owner.TrackPendingOp()
go func() {
defer owner.CompletePendingOp()
mu.Lock()
count++
mu.Unlock()
}()
Always return cleanup functions from effects that create resources:
firm.Effect(owner, func() firm.CleanUp {
connection := openConnection(url.Get())
return func() {
connection.Close() // Runs when effect reruns or owner is disposed
}
}, nil)
For performance and clarity, specify explicit dependencies when known:
firm.Effect(owner, func() firm.CleanUp {
fmt.Println("User:", firstName.Get(), lastName.Get())
return nil
}, []firm.Reactive{firstName, lastName})
MIT License