Communicating Between Components
This page covers patterns for communicating between Litho components, including parent-child, sibling, and external communication.
Overview
Litho components communicate through several patterns:
| Pattern | Direction | Use Case |
|---|---|---|
| Callback props | Child → Parent | Notify parent of events |
| Props updates | Parent → Child | Pass new data to children |
| Controllers | Parent → Child | Trigger imperative actions |
| Parent mediation | Sibling ↔ Sibling | Coordinate between siblings |
| StateFlow/Listeners | External → Component | React to external changes |
| Observer callbacks | Component → External | Notify external systems |
A Component's render method may run on a background thread. It's important that any communication mechanisms do not perform side-effects from render. Side-effects can instead be performed from main thread events like onClick and useEffect.
Dispatching events from child to parent
Sometimes a parent needs to know when an event happens in a child component. For example, a ProductCard needs to know when its AddToCartButton is clicked. The parent passes a lambda callback to the child:
class ProductCard : KComponent() {
override fun ComponentScope.render(): Component {
val cartItemCount = useState { 0 }
return Column {
child(Text(text = "Product: Wireless Headphones"))
child(Text(text = "Price: $99.99"))
child(Text(text = "Items in cart: ${cartItemCount.value}"))
child(AddToCartButton(onAddToCart = { cartItemCount.update { it + 1 } }))
}
}
}
The child component invokes the callback to notify the parent that the action occurred:
class AddToCartButton(private val onAddToCart: () -> Unit) : KComponent() {
override fun ComponentScope.render(): Component {
return Text(text = "Add to Cart", style = Style.onClick { onAddToCart() })
}
}
Passing new data from parent to child
When a parent component needs to update a child, it passes new props. For example, a CartScreen updates a CartTotal component when items are added. The parent triggers a state update, and the child receives the latest value through its props:
class CartScreen : KComponent() {
override fun ComponentScope.render(): Component {
val totalPrice = useState { 0.0 }
return Column {
child(Text(text = "Your Shopping Cart"))
// When clicked, add an item and update the total
child(
Text(
text = "Add $29.99 item",
style = Style.onClick { totalPrice.update { it + 29.99 } },
),
)
// CartTotal receives the current total as a prop
child(CartTotal(total = totalPrice.value))
}
}
}
class CartTotal(private val total: Double) : KComponent() {
override fun ComponentScope.render(): Component {
return Text(text = "Total: $${String.format("%.2f", total)}")
}
}
Triggering an action on a child from a parent
Sometimes a parent needs to trigger an imperative action on a child rather than just passing new props. For example, a CheckoutForm might need to trigger validation on a PaymentCardInput. Use the Controllers Pattern for this:
class PaymentInputController {
private var validationListener: (() -> Unit)? = null
fun setValidationListener(listener: () -> Unit) {
validationListener = listener
}
fun clearValidationListener() {
validationListener = null
}
fun validate() {
validationListener?.invoke()
}
}
The parent creates the controller and triggers validation when the user submits:
class CheckoutForm : KComponent() {
override fun ComponentScope.render(): Component {
val controller = useCached { PaymentInputController() }
return Column {
child(PaymentCardInput(controller = controller))
child(Text(text = "Place Order", style = Style.onClick { controller.validate() }))
}
}
}
The child registers a listener with the controller:
class PaymentCardInput(private val controller: PaymentInputController) : KComponent() {
override fun ComponentScope.render(): Component {
val isValidated = useState { false }
useEffect(controller) {
controller.setValidationListener { isValidated.update { true } }
onCleanup { controller.clearValidationListener() }
}
return Text(text = if (isValidated.value) "Card validated ✓" else "Enter card details...")
}
}
Communicating between siblings
Two sibling components cannot communicate directly. Instead, use a parent as mediator pattern:
- Child A dispatches an event to the parent
- Parent updates state or triggers an action
- Child B receives the update through props or a controller
This ensures a unidirectional data flow that's easier to debug and maintain.
For example, a PromoCodeInput applies a discount that updates an OrderSummary. The PromoCodeInput sends the discount to the parent:
class PromoCodeInput(private val onPromoApplied: (discountPercent: Int) -> Unit) : KComponent() {
override fun ComponentScope.render(): Component {
return Text(
text = "Apply 20% discount",
style = Style.onClick { onPromoApplied(20) },
)
}
}
The OrderSummary receives the discount as a prop and displays the updated total:
class OrderSummary(private val discountPercent: Int) : KComponent() {
override fun ComponentScope.render(): Component {
val subtotal = 99.99
val discount = subtotal * discountPercent / 100
val total = subtotal - discount
return Column {
child(Text(text = "Subtotal: $${String.format("%.2f", subtotal)}"))
if (discountPercent > 0) {
child(Text(text = "Discount: -$${String.format("%.2f", discount)} ($discountPercent%)"))
}
child(Text(text = "Total: $${String.format("%.2f", total)}"))
}
}
}
The CheckoutScreen mediates between the siblings by holding the shared state:
class CheckoutScreen : KComponent() {
override fun ComponentScope.render(): Component {
val discountPercent = useState { 0 }
return Column {
// PromoCodeInput sends discount via callback
child(PromoCodeInput(onPromoApplied = { percent -> discountPercent.update { percent } }))
// OrderSummary receives discount via prop
child(OrderSummary(discountPercent = discountPercent.value))
}
}
}
Updating a component from outside Litho
A component tree can be updated by creating a new root component with new props and calling ComponentTree#setRootAsync.
There are two main approaches for external systems to communicate with components:
With a StateFlow (Recommended)
Use StateFlow when you have reactive data streams. This is the preferred approach for Kotlin as it integrates naturally with coroutines and provides automatic lifecycle management. For example, a CartBadge can observe the cart item count from a repository:
class CartBadgeWithFlow(private val cartItemCountFlow: StateFlow<Int>) : KComponent() {
override fun ComponentScope.render(): Component {
val itemCount = useFlow(cartItemCountFlow)
return Text(text = "Cart (${itemCount ?: 0})")
}
}
With a listener pattern
Use a listener pattern when working with callback-based APIs or when you need more control over subscription lifecycle:
interface CartUpdateListener {
fun addListener(listener: () -> Unit)
fun removeListener(listener: () -> Unit)
}
class CartBadge(private val cartListener: CartUpdateListener) : KComponent() {
override fun ComponentScope.render(): Component {
val itemCount = useState { 0 }
useEffect(cartListener) {
val listener: () -> Unit = { itemCount.update { it + 1 } }
cartListener.addListener(listener)
onCleanup { cartListener.removeListener(listener) }
}
return Text(text = "Cart (${itemCount.value})")
}
}
Communicating externally from a Component
To send events from a component to a listener outside of the Litho hierarchy, define an observer interface and invoke it from a component event handler. For example, a PurchaseButton can notify an AnalyticsService when clicked:
interface AnalyticsService {
fun trackPurchaseClicked(productId: String, price: Double)
}
class PurchaseButton(
private val productId: String,
private val price: Double,
private val analyticsService: AnalyticsService,
) : KComponent() {
override fun ComponentScope.render(): Component {
return Text(
text = "Buy Now - $$price",
style = Style.onClick { analyticsService.trackPurchaseClicked(productId, price) },
)
}
}