Skip to content

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:

React component
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:

Kotlin signal example
val count = mutableSignalOf(0)
// Derived state - read-only Signal type
val 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

Signal vs MutableSignal types
// Source state - I can write to this
val count = mutableSignalOf(0) // MutableSignal<Int>
// Derived state - computed from count, read-only
val 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 setValue

The 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:

Kotlin signals with composition
val firstName = mutableSignalOf("John")
val lastName = mutableSignalOf("Doe")
// combine returns Signal<String> - read-only, auto-memoized
val 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 recomputation
React with useState and useMemo
import { 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:

React - no protection against mistakes
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:

Kotlin - compiler prevents mistakes
val items = mutableSignalOf<List<Item>>(emptyList())
// filtered is Signal<List<Item>> - read-only
val 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 derived

I 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.

Kotlin form with signals
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
// }
}
React form with hooks
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] from useState

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

Wrong - trying to modify Signal
val count = mutableSignalOf(0)
val doubled = count.map { it * 2 } // Signal<Int>
// ✗ This won't compile
doubled.value = 10

I 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

Wrong - overusing MutableSignal
val firstName = mutableSignalOf("John")
val lastName = mutableSignalOf("Doe")
// ✗ Don't do this - fullName should be derived
val fullName = mutableSignalOf("")
// Then manually update it when names change
firstName.value = "Jane"
fullName.value = "${firstName.value} ${lastName.value}" // Error-prone!

Better approach:

Correct - derived state
val firstName = mutableSignalOf("John")
val lastName = mutableSignalOf("Doe")
// ✓ Derived state - auto-updates
val fullName: Signal<String> = combine(firstName, lastName) { first, last ->
"$first $last"
}
// Just update the source
firstName.value = "Jane" // fullName updates automatically

Mistake 3: Expecting React’s re-render model

In React, everything runs again when state changes:

React - re-render based
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:

Kotlin - fine-grained updates
val count = mutableSignalOf(0)
// Only signals that depend on count update
val doubled = count.map { it * 2 } // Updates when count changes
val message = signalOf("Static") // Doesn't update
// No "re-render" - individual signals track dependencies

How I Use Signals in Practice

After some practice, I’ve found a good pattern:

Practical signal usage
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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments