How to Understand Kotlin Signals vs React State: A Developer's Comparison
Purpose
When I moved from React to Kotlin, I got confused about state management. I knew useState well, but Kotlin has these things called Signal and MutableSignal. They looked similar but worked differently.
I want to explain the difference between React’s state pattern and Kotlin’s signal pattern, so you don’t have to figure it out the hard way like I did.
The Problem
In React, I used to write state like this:
import { useState } from 'react';
function Counter() { const [count, setCount] = useState(0);
// Derived state - recalculates on every render const isPositive = count > 0;
// Memoized version - requires manual optimization const isPositiveMemoized = useMemo(() => count > 0, [count]);
return ( <div> <p>Count: {count}</p> <p>Positive: {isPositiveMemoized}</p> <button onClick={() => setCount(count + 1)}>+</button> </div> );}This works fine. But I had two problems:
- Derived values recalculate on every render unless I remember
useMemo - No type difference between source state and derived state
- I can accidentally treat derived values as source state
When I started with Kotlin, I saw this pattern:
val count = mutableSignalOf(0)
// Derived state - read-only Signal typeval isPositive: Signal<Boolean> = count.map { it > 0 }
count.value = 5 // ✓ Works
isPositive.value = false // ✗ Compile error!Wait, why can’t I modify isPositive? And what’s the difference between Signal and MutableSignal?
How Kotlin Signals Work
After some reading, I learned that Kotlin signals separate two concepts:
1. MutableSignal<T> - Source state that can change 2. Signal<T> - Derived state that’s read-only
// Source state - I can write to thisval count = mutableSignalOf(0) // MutableSignal<Int>
// Derived state - computed from count, read-onlyval isPositive: Signal<Boolean> = count.map { it > 0 }
// Type system prevents this:// isPositive.value = false // Error: Signal has no setValue
// But this works fine:count.value = 5 // MutableSignal allows setValueThe type system forces me to think about which state is the source and which is derived. I can’t accidentally assign a value to something that should be computed.
Compare with React Pattern
Let me show you the same logic in both languages:
val firstName = mutableSignalOf("John")val lastName = mutableSignalOf("Doe")
// combine returns Signal<String> - read-only, auto-memoizedval fullName: Signal<String> = combine(firstName, lastName) { first, last -> "$first $last"}
// ✓ Automatic memoization// ✓ Read-only type prevents accidental writes// ✓ No render cycle needed
// Later in code:firstName.value = "Jane" // Triggers fullName recomputationimport { useState, useMemo } from 'react';
function NameForm() { const [firstName, setFirstName] = useState("John"); const [lastName, setLastName] = useState("Doe");
// Manual memoization required const fullName = useMemo( () => `${firstName} ${lastName}`, [firstName, lastName] );
// ⚠️ No type difference between source and derived // ⚠️ Must remember useMemo for performance // ⚠️ Everything recalculates on render if I forget deps
return ( <input value={firstName} onChange={(e) => setFirstName(e.target.value)} /> );}The Kotlin version doesn’t need me to remember optimization. The signal library automatically caches the fullName value and only recomputes when firstName or lastName changes.
Why the Type Difference Matters
I ran into this pattern in React:
const [items, setItems] = useState([]);const filtered = items.filter(item => item.active);
// Later in code, I might accidentally do this:filtered = somethingElse; // JavaScript allows this!
// Or treat it as source state:setFiltered(newItems); // Where did setFiltered come from?The compiler can’t help me because there’s no type difference between source and derived state.
In Kotlin, the type system catches this:
val items = mutableSignalOf<List<Item>>(emptyList())
// filtered is Signal<List<Item>> - read-onlyval filtered: Signal<List<Item>> = items.map { it.filter { item -> item.active }}
// ✓ Compiler error: Signal has no setValue// filtered.value = somethingElse // Won't compile!
// ✓ Type makes it clear: items is source, filtered is derivedI can’t accidentally treat derived state as source state because the types won’t allow it.
Practical Example: Form Validation
Let me show you a real example where this pattern helps.
class LoginForm { val email = mutableSignalOf("") val password = mutableSignalOf("")
// Derived validation - read-only, auto-updating val isValid: Signal<Boolean> = combine(email, password) { e, p -> e.contains("@") && p.length >= 8 }
// Derived error message - auto-memoized val errorMessage: Signal<String?> = isValid.map { valid -> if (valid) null else "Invalid email or password too short" }
// Only source state can be modified fun updateEmail(newEmail: String) { email.value = newEmail // ✓ MutableSignal allows this }
// This won't compile: // fun forceValid() { // isValid.value = true // ✗ Signal has no setValue // }}import { useState, useMemo } from 'react';
function LoginForm() { const [email, setEmail] = useState(""); const [password, setPassword] = useState("");
// Must remember useMemo const isValid = useMemo( () => email.includes("@") && password.length >= 8, [email, password] );
const errorMessage = useMemo( () => isValid ? null : "Invalid email or password too short", [isValid] );
// Nothing prevents me from doing this: // const [isValid2, setIsValid2] = useState(false); // setIsValid2(true); // Now I have two sources of truth!
return ( <form> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <input value={password} onChange={(e) => setPassword(e.target.value)} /> {!isValid && <p>{errorMessage}</p>} </form> );}In the Kotlin version, the type system makes it impossible to accidentally create a second source of truth for validation. The isValid signal is clearly derived from email and password.
Key Differences I Found
After working with both patterns, here’s what I learned:
Source state:
- Kotlin:
MutableSignal<T>- explicit writable type - React:
[T, Setter]fromuseState
Derived state:
- Kotlin:
Signal<T>- read-only, auto-memoized - React: Computed value + manual
useMemo
Type safety:
- Kotlin: Compile-time enforcement (can’t write to Signal)
- React: No distinction between source and derived
Performance:
- Kotlin: Automatic caching (signals track dependencies)
- React: Manual optimization with
useMemo
Reactivity:
- Kotlin: Fine-grained (signals update independently)
- React: Render-based (component re-renders, hooks run)
Common Mistakes I Made
When I started with Kotlin signals, I made these errors:
Mistake 1: Treating Signal as mutable
val count = mutableSignalOf(0)val doubled = count.map { it * 2 } // Signal<Int>
// ✗ This won't compiledoubled.value = 10I had to remember: if I need to modify it, use mutableSignalOf. If it’s computed, use map or combine and accept it’s read-only.
Mistake 2: Using MutableSignal everywhere
val firstName = mutableSignalOf("John")val lastName = mutableSignalOf("Doe")
// ✗ Don't do this - fullName should be derivedval fullName = mutableSignalOf("")
// Then manually update it when names changefirstName.value = "Jane"fullName.value = "${firstName.value} ${lastName.value}" // Error-prone!Better approach:
val firstName = mutableSignalOf("John")val lastName = mutableSignalOf("Doe")
// ✓ Derived state - auto-updatesval fullName: Signal<String> = combine(firstName, lastName) { first, last -> "$first $last"}
// Just update the sourcefirstName.value = "Jane" // fullName updates automaticallyMistake 3: Expecting React’s re-render model
In React, everything runs again when state changes:
function Component() { const [count, setCount] = useState(0);
// This entire function runs again on every state change console.log("Component rendered");
return <button onClick={() => setCount(count + 1)}>{count}</button>;}Kotlin signals work differently - they use fine-grained reactivity:
val count = mutableSignalOf(0)
// Only signals that depend on count updateval doubled = count.map { it * 2 } // Updates when count changesval message = signalOf("Static") // Doesn't update
// No "re-render" - individual signals track dependenciesHow I Use Signals in Practice
After some practice, I’ve found a good pattern:
class UserDashboard { // Source state - things that change from user actions val users = mutableSignalOf<List<User>>(emptyList()) val filterText = mutableSignalOf("") val sortBy = mutableSignalOf<"name" | "email">("name")
// Derived state - computed automatically val filteredUsers: Signal<List<User>> = combine(users, filterText) { userList, text -> if (text.isEmpty()) userList else userList.filter { it.name.contains(text, ignoreCase = true) } }
val sortedUsers: Signal<List<User>> = combine(filteredUsers, sortBy) { list, sort -> when (sort) { "name" -> list.sortedBy { it.name } "email" -> list.sortedBy { it.email } else -> list } }
// Usage fun loadUsers(newUsers: List<User>) { users.value = newUsers // Updates sortedUsers automatically }
fun setFilter(text: String) { filterText.value = text // Updates sortedUsers automatically }
// No need to manually recalculate sortedUsers}The signal library handles the dependency graph. When I update users, filteredUsers updates, which triggers sortedUsers to update. I don’t need to manually chain these updates.
Summary
In this post, I showed how Kotlin signals differ from React’s state management. The key points are:
- Kotlin separates source state (
MutableSignal<T>) from derived state (Signal<T>) - The type system prevents accidental modifications to derived values
- Signals automatically memoize without manual optimization
- Fine-grained reactivity means only dependent signals update, not entire components
If you’re coming from React, think of MutableSignal as useState and Signal as useMemo with better type safety. The enforced separation helps avoid state management bugs and makes the data flow clearer.
I think the signal pattern is particularly useful for server-side frameworks like Vaadin, where you want React-like declarative patterns but need the type safety and performance of Kotlin.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 How I brought the declarative pattern to Vaadin with reactive signals in Kotlin
- 👨💻 SolidJS Signals Documentation
- 👨💻 Kotlin StateFlow Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments