useSignal Hook
The useSignal
hook integrates Fluxus with Airstream, a Functional Reactive Programming (FRP) library for Scala.js. This allows you to manage state outside of the component tree and share it between components.
Basic Usage
import com.raquo.airstream.state.Var
// Create a signal outside of components
val countSignal = Var(0)
val Counter = () => {
// Connect the component to the signal
val count = useSignal(countSignal)
div(
p(s"Count: $count"),
button(
onClick := (() => countSignal.update(_ + 1)),
"Increment"
)
)
}
Where: - countSignal
is an Airstream Var
(a read-write signal) - count
is the current value of the signal, which will update whenever the signal changes - Changes to the signal trigger re-renders of any component using useSignal
with that signal
Why Use Signals?
Signals offer several advantages for state management:
- Shared State: Multiple components can subscribe to the same signal
- Derived State: Signals can be transformed and combined
- Separation of Concerns: State logic can be separated from UI components
- Reactive Updates: Components automatically update when signals change
- Testability: Signal-based state is easy to test independently
Examples
Shared Counter
import com.raquo.airstream.state.Var
// Shared state that lives outside components
object CounterState {
val count = Var(0)
def increment(): Unit = count.update(_ + 1)
def decrement(): Unit = count.update(_ - 1)
def reset(): Unit = count.set(0)
}
// Display component
val CountDisplay = () => {
val count = useSignal(CounterState.count)
div(cls := "counter-display", s"Count: $count")
}
// Controls component
val CountControls = () => {
div(
cls := "counter-controls",
button(
onClick := (() => CounterState.decrement()),
"−"
),
button(
onClick := (() => CounterState.reset()),
"Reset"
),
button(
onClick := (() => CounterState.increment()),
"+"
)
)
}
// Parent component that uses both
val CounterApp = () => {
div(
cls := "counter-app",
h1("Shared Counter"),
CountDisplay <> (),
CountDisplay <> (), // Multiple instances share the same state
CountControls <> ()
)
}
Derived Signals
import com.raquo.airstream.state.Var
import com.raquo.airstream.core.Transaction
object CartState {
case class Item(id: String, name: String, price: Double, quantity: Int)
val items = Var(Vector.empty[Item])
// Derived signals
val totalItems = items.signal.map(_.map(_.quantity).sum)
val subtotal = items.signal.map(_.map(item => item.price * item.quantity).sum)
val tax = subtotal.map(_ * 0.07) // 7% tax
val total = subtotal.combineWith(tax)(_ + _)
def addItem(item: Item): Unit = {
Transaction { _ =>
items.update { currentItems =>
currentItems.find(_.id == item.id) match {
case Some(_) =>
// Item exists, increment quantity
currentItems.map { i =>
if (i.id == item.id) i.copy(quantity = i.quantity + 1)
else i
}
case None =>
// New item
currentItems :+ item
}
}
}
}
def removeItem(id: String): Unit = {
items.update(_.filterNot(_.id == id))
}
def updateQuantity(id: String, quantity: Int): Unit = {
if (quantity <= 0) {
removeItem(id)
} else {
items.update(_.map { item =>
if (item.id == id) item.copy(quantity = quantity)
else item
})
}
}
}
val ShoppingCart = () => {
val items = useSignal(CartState.items)
val subtotal = useSignal(CartState.subtotal)
val tax = useSignal(CartState.tax)
val total = useSignal(CartState.total)
div(
cls := "shopping-cart",
h2("Shopping Cart"),
// Item list
if (items.isEmpty) {
p("Your cart is empty")
} else {
div(
cls := "cart-items",
items.map { item =>
div(
key := item.id,
cls := "cart-item",
div(cls := "item-name", item.name),
div(cls := "item-price", f"$${item.price}%.2f"),
div(
cls := "item-quantity",
button(
onClick := (() => CartState.updateQuantity(item.id, item.quantity - 1)),
"−"
),
span(item.quantity.toString),
button(
onClick := (() => CartState.updateQuantity(item.id, item.quantity + 1)),
"+"
)
),
div(
cls := "item-total",
f"$${item.price * item.quantity}%.2f"
),
button(
cls := "remove-item",
onClick := (() => CartState.removeItem(item.id)),
"×"
)
)
}
)
},
// Cart summary
div(
cls := "cart-summary",
div(
cls := "summary-row",
span("Subtotal:"),
span(f"$$$subtotal%.2f")
),
div(
cls := "summary-row",
span("Tax:"),
span(f"$$$tax%.2f")
),
div(
cls := "summary-row total",
span("Total:"),
span(f"$$$total%.2f")
)
)
)
}
val ProductCatalog = () => {
val products = Vector(
CartState.Item("p1", "T-Shirt", 19.99, 1),
CartState.Item("p2", "Jeans", 49.99, 1),
CartState.Item("p3", "Shoes", 79.99, 1)
)
div(
cls := "product-catalog",
h2("Products"),
div(
cls := "products",
products.map { product =>
div(
key := product.id,
cls := "product",
div(cls := "product-name", product.name),
div(cls := "product-price", f"$${product.price}%.2f"),
button(
onClick := (() => CartState.addItem(product)),
"Add to Cart"
)
)
}
)
)
}
Form State Management
import com.raquo.airstream.state.Var
import com.raquo.airstream.core.Transaction
case class FormData(
username: String = "",
email: String = "",
password: String = ""
)
case class FormErrors(
username: Option[String] = None,
email: Option[String] = None,
password: Option[String] = None
)
object FormState {
val data = Var(FormData())
val errors = Var(FormErrors())
val isSubmitting = Var(false)
// Derived signals
val isValid = data.signal.map { d =>
d.username.nonEmpty &&
d.email.nonEmpty && d.email.contains("@") &&
d.password.length >= 8
}
def setUsername(value: String): Unit = {
Transaction { _ =>
data.update(_.copy(username = value))
validateUsername()
}
}
def setEmail(value: String): Unit = {
Transaction { _ =>
data.update(_.copy(email = value))
validateEmail()
}
}
def setPassword(value: String): Unit = {
Transaction { _ =>
data.update(_.copy(password = value))
validatePassword()
}
}
private def validateUsername(): Unit = {
val usernameError = if (data.now().username.isEmpty) {
Some("Username is required")
} else {
None
}
errors.update(_.copy(username = usernameError))
}
private def validateEmail(): Unit = {
val emailError = if (data.now().email.isEmpty) {
Some("Email is required")
} else if (!data.now().email.contains("@")) {
Some("Invalid email format")
} else {
None
}
errors.update(_.copy(email = emailError))
}
private def validatePassword(): Unit = {
val passwordError = if (data.now().password.isEmpty) {
Some("Password is required")
} else if (data.now().password.length < 8) {
Some("Password must be at least 8 characters")
} else {
None
}
errors.update(_.copy(password = passwordError))
}
def validateAll(): Boolean = {
validateUsername()
validateEmail()
validatePassword()
errors.now().username.isEmpty &&
errors.now().email.isEmpty &&
errors.now().password.isEmpty
}
def submitForm(): Unit = {
if (validateAll()) {
isSubmitting.set(true)
// Simulate API call
js.timers.setTimeout(2000) {
isSubmitting.set(false)
// Reset form on success
data.set(FormData())
}
}
}
}
val SignupForm = () => {
val data = useSignal(FormState.data)
val errors = useSignal(FormState.errors)
val isValid = useSignal(FormState.isValid)
val isSubmitting = useSignal(FormState.isSubmitting)
def handleSubmit(e: dom.Event): Unit = {
e.preventDefault()
FormState.submitForm()
}
form(
onSubmit := handleSubmit,
div(
cls := "form-group",
label("Username"),
input(
typ := "text",
value := data.username,
onInput := ((e: dom.Event) =>
FormState.setUsername(e.target.asInstanceOf[dom.html.Input].value)
)
),
errors.username.map(error =>
div(cls := "error", error)
)
),
div(
cls := "form-group",
label("Email"),
input(
typ := "email",
value := data.email,
onInput := ((e: dom.Event) =>
FormState.setEmail(e.target.asInstanceOf[dom.html.Input].value)
)
),
errors.email.map(error =>
div(cls := "error", error)
)
),
div(
cls := "form-group",
label("Password"),
input(
typ := "password",
value := data.password,
onInput := ((e: dom.Event) =>
FormState.setPassword(e.target.asInstanceOf[dom.html.Input].value)
)
),
errors.password.map(error =>
div(cls := "error", error)
)
),
button(
typ := "submit",
disabled := (!isValid || isSubmitting),
if (isSubmitting) "Signing up..." else "Sign Up"
)
)
}
Signal Composition
Airstream provides powerful operators for working with signals:
Transforming Signals
// Map a signal to a different type
val count = Var(0)
val doubledCount = count.signal.map(_ * 2)
val isEven = count.signal.map(_ % 2 == 0)
val countString = count.signal.map(_.toString)
// Use in a component
val DisplayComponent = () => {
val doubled = useSignal(doubledCount)
val even = useSignal(isEven)
div(
p(s"Doubled value: $doubled"),
p(s"Is even: $even")
)
}
Combining Signals
val firstName = Var("John")
val lastName = Var("Doe")
// Combine two signals
val fullName = firstName.signal.combineWith(lastName.signal)((first, last) => s"$first $last")
// Use in a component
val GreetingComponent = () => {
val name = useSignal(fullName)
div(s"Hello, $name!")
}
Signal Filtering
val value = Var(0)
// Only emit even values
val evenValues = value.signal.filter(_ % 2 == 0)
// Only emit when value changes
val distinctValues = value.signal.distinct
// Skip the first N values
val afterThreeValues = value.signal.skip(3)
Handling Side Effects
You can perform side effects when signals change:
val authToken = Var(Option.empty[String])
// Log to console when token changes
authToken.signal.foreach { token =>
console.log(s"Token changed: $token")
}
// Save to localStorage when token changes
authToken.signal.foreach { token =>
token match {
case Some(t) => dom.window.localStorage.setItem("token", t)
case None => dom.window.localStorage.removeItem("token")
}
}
Implementation Details
The useSignal
hook works by:
- Setting up an Airstream observer that calls the component’s state setter when the signal changes
- Cleaning up the observer when the component unmounts
- Subscribing to the signal only once during the component’s lifecycle
Here’s a simplified implementation:
def useSignal[A](signal: Var[A]): A = {
// Initialize Airstream's transaction system if needed
SignalHook
// Use state to trigger re-renders when the signal changes
val (value, setValue, _) = useState[A](signal.now())
useEffect(
() => {
val owner = new EffectOwner()
val observer = Observer[A](setValue)
// Observe the signal with the observer
val subscription = signal.signal.addObserver(observer)(owner)
// Cleanup on unmount or signal change
() => {
Transaction { _ =>
subscription.kill()
owner.cleanup()
}
}
},
Seq(signal), // Only re-subscribe if the signal reference changes
)
value
}
Best Practices
Signal Ownership
Signals should typically be owned by a module or service, not by components:
// Good: Signal owned by a module
object UserStore {
val currentUser = Var(Option.empty[User])
def login(username: String, password: String): Future[User] = {
// Implementation
}
def logout(): Unit = {
currentUser.set(None)
}
}
// Bad: Signal created inside component
val UserProfile = () => {
val userSignal = Var(Option.empty[User]) // Don't do this!
// Component body
}
Separating State Logic
Use signals to separate state management logic from UI components:
// State logic
object CounterLogic {
val count = Var(0)
def increment(): Unit = count.update(_ + 1)
def decrement(): Unit = count.update(_ - 1)
def reset(): Unit = count.set(0)
}
// UI component
val Counter = () => {
val count = useSignal(CounterLogic.count)
div(
p(s"Count: $count"),
button(onClick := (() => CounterLogic.decrement()), "−"),
button(onClick := (() => CounterLogic.reset()), "Reset"),
button(onClick := (() => CounterLogic.increment()), "+")
)
}
Combining with useEffect
You may need to use useEffect
with signals for more complex interactions:
val SearchComponent = () => {
val query = useSignal(SearchStore.query)
val results = useSignal(SearchStore.results)
// Run a search when query changes
useEffect(
() => {
if (query.nonEmpty) {
SearchStore.performSearch()
}
},
Seq(query) // Depend on the query value
)
// Component body
}
Type Safety
Signals inherit Scala’s type system, providing compile-time safety:
// Type-safe signals
val nameSignal = Var("John")
val ageSignal = Var(30)
// Won't compile: type mismatch
nameSignal.set(42)
ageSignal.set("Forty")
Performance Considerations
- Minimize Signal Updates: Each signal update potentially causes re-renders
- Granular Signals: Use multiple small signals rather than one large one
- Derived Signals: Use
.map
,.combineWith
, etc. instead of deriving values in components - Fine-grained Subscriptions: Subscribe only to the signals you need
Comparison with useState
Feature | useSignal | useState |
---|---|---|
Scope | Global or local | Component-local only |
Sharing | Easily shared between components | Requires prop drilling or context |
Updates | Any code with access can update | Only the component can update |
Composition | Rich composition API | Limited composition |
Update Tracking | Reactive (automatic) | Manual |
Side Effects | Can trigger side effects directly | Requires useEffect |
Memory Management | Requires explicit cleanup | Automatic |
When to Use useSignal vs. useState
Use useSignal when: - State needs to be shared between components - State logic should be separated from UI - You need derived/computed state - You need to react to state changes outside components
Use useState when: - State is truly local to a component - Component state is simple - You want automatic cleanup - You don’t need to share state
Often the best approach is to combine both: use signals for shared application state and useState for component-local state.