easily building reactive web-apps in pure Kotlin

made with love by jamowei & jwstegemann

Single Page Apps

Vorteile

  • sehr reaktiv
  • fühlt sich an wie eine native Anwendung
  • weniger Netzwerkverkehr -> kürzere Ladezeiten
  • Frontend ist unabhängig vom Backend
  • Entlastung der Backendsysteme

Nachteile

  • Nutzung von JavaScript notwendig (nicht typisiert)
  • weitere Entwicklungsumgebung sowie Know-How für JS
  • komplexeres State Management

State-Management

Circle of Life

React js

fritz2

Separation of Concerns

//Store
val store = object : RootStore<String>("", id = "model") {
  // Handler
  val addADot = handle { model ->
  	"$model."
  }
}
//HTML Code
val gettingstarted = html {
    div {
        div("form-group") {
            label(`for` = store.id) {
                text("Input")
            }
            input("form-control", id = store.id) {
                placeholder = const("Add some input")
                value = store.data

                store.update <= changes.values()
            }
        }
        div("form-group") {
            button("btn btn-primary") {
                text("Add a dot")
                store.addADot <= clicks
            }
        }
    }
}
// Mount mit Hilfe der Id des Zielelements der index.html
gettingstarted.mount("target")

Asynchronous Operations

val store = object : RootStore<String>("") {
	
    // Handler der den neuen Wert in einer Alert-Meldung ausgibt
    val alert = handle<String> { model, result ->
    	window.alert(result)
        result.trim()
    }
    
    // Erzeugung eines asynchronen Applicators
    val load = apply<Unit, String> {
       val result: String = longRunning() // z.B. ein AJAX-Request
       result
    } andThen alert
    	
}


val component = html {
    div {
    	button {
        	store.load <= clicks
        }
    }
}

Easy Inter-Store-Communication

// Store für eine Person
val personStore = object : RootStore<Person>(Person(createUUID())) {
    
    // Handler mit der zusätzlichen Fähigkeit Daten
    // an verbundene Stores zu übermitteln
    val save = handleAndEmit<Unit, Person> { person ->
        offer(person) // sendet den neuen Wert an verbundene Stores
        Person(createUUID())
    }
}

// Store für eine Liste von Personen
val listStore = object : RootStore<List<Person>>(emptyList()) {
   
    // Handler für das Hinzufügen weiterer Personen zur Liste
    val add: Handler<Person> = handle { list, person ->
         list + person
    }
}


// Herstellen der Verbindung zwischen den beiden Handlern der Stores
listStore.add <= personStore.save

Clean Code

Keep it small and simple (KISS)

~1.500 lines of code

keine externen Abhängigkeiten

100% Kotlin

Write typesafe HTML

val gettingstarted = html {
    div {
        div("form-group") {
            label(`for` = store.id) {
                text("Input")
            }
            input("form-control", id = store.id) {
                placeholder = const("Add some input")
                value = store.data

                store.update <= changes.values()
            }
        }
        div("form-group") {
            button("btn btn-primary") {
                text("Add a dot")
                store.addADot <= clicks
            }
        }
    }
}

gettingstarted.mount("target")

Datenmodell ohne Boilerplate

@Lenses
data class Person(
    override val id: String,
    val name: String,
    val birthday: String,
    val address: Address = Address(),
    val activities: List<Activity> = emptyList()
) : WithId


@Lenses
data class Address(
    val street: String,
    val number: String,
    val postalCode: String,
    val city: String
)

Deklarativ statt deskriptiv

// some methods on Flow<T>
fun Flow<T>.count(predicate: (T) -> Boolean): Int
fun Flow<T>.filter(predicate: (T) -> Boolean): Flow<T>
fun Flow<T>.map(transform: (value: T) -> R): Flow<R>
fun Flow<T>.flatMapLatest(transform: (value: T) -> Flow<R>): Flow<R>
fun Flow<T>.fold(initial: R, operation: (acc: R, value: T) -> R): R
fun Flow<T>.onEach(action: (T) -> Unit): Flow<T>
fun Flow<T>.reduce(operation: (accumulator: S, value: T) -> S): S
fun Flow<T>.takeWhile(predicate: (T) -> Boolean): Flow<T>
fun Flow<T1>.zip(other: Flow<T2>, transform: (T1, T2) -> R): Flow<R>



val activities = person.activities.filter { it.like }.map { it.name }.joinToString()

val count = data.map { todos -> todos.count { !it.completed } }
count.map { "$it item${if (it != 1) "s" else ""} left" }

Don't Repeat Yourself (DRY)

interface CrudStore<T> {
  val save: Handler<Unit>

  //...
}

open class LocalStorageCrudStore<T> : CrudStore<T> {
  
  override val save = handle { model ->
    // write model to local storage        
  }
  
  //...
}


val myStore = object : LocalStorageCrudStore {
    
    // more handlers here...
}


html {
    button {
        myStore.save <= clicks
    }
}

Toolchain

Buildsskripte, Dependency-Management, Unit-Tests, etc. wie gewohnt in Gradle

IntelliJ IDEA bietet hervorragende Unterstützung beim Refactoring, Formatierung, Insights, etc.

Debugging im Kotlin-Code direkt im Browser oder über Plugin sogar in der IDE.

Extrem kurze Turnarounds bei Änderungen im Continuous-Modus (vergleichbar zu purem JavaScript).

Styling

Base Classes

val component = html {

    div {
    	div("form-group") {
            label {
              text("Value")
            }

            div("form-control") {
              store.data.bind()
              attr("readonly", "true")
            }
        }

        div("form-group") {
            button("btn btn-primary") {
              text("Add a dot")
              store.addADot <= clicks
            }
        }
    }
}


            

Dynamic Classes

val component = html {
	
    div {
    
        div {
            // Verwendet einen String
            className = router.routes
                          .map {if (it == route) "selected" else ""}
        }

        div {
            // Verwendet eine List<String>
            classList = const(listOf("class1", "class2", "class3"))
        }    

        div {
            // Verwendet eine Map<String, Boolean>
            classMap = toDoStore.data.map { mapOf(
              "completed" to it.completed,
              "editing" to it.editing
            )}
        }
    }
}            

Validation

Validation objects

data class Message(override val id: String,
                   val status: Status,
                   val text: String): ValidationMessage {
                   
    override fun failed(): Boolean = status > Status.Valid
}

enum class Status(val inputClass: String,
                  val messageClass: String) {
                 
    Valid("is-valid", "valid-feedback"),
    Invalid("is-invalid", "invalid-feedback")
}

Validator for data class

object PersonValidator: Validator<Person, Message, String>() {

    override fun validate(data: Person, metadata: String): List<Message> {
        // working with mutable list here is much more easier
        val msgs = mutableListOf<Message>()
        val idStore = ModelIdRoot<Person>()

        // validate name
        if(data.name.trim().isBlank())
            msgs.add(Message(idStore.sub(Person.name).id, Status.Invalid, "Please provide a name"))
        else
            msgs.add(Message(idStore.sub(Person.name).id, Status.Valid, "Good name"))

        // validate the birthday
        when {
            data.birthday == Date(1900, 1, 1) -> {
                msgs.add(Message(idStore.sub(Person.birthday).id, Status.Invalid, "Please provide a birthday"))
            }
            data.birthday.year < 1900 -> {
                msgs.add(Message(idStore.sub(Person.birthday).id, Status.Invalid, "Its a bit to old"))
            }
            data.birthday.year > DateTime.now().yearInt -> {
                msgs.add(Message(idStore.sub(Person.birthday).id, Status.Invalid, "Cannot be in future"))
            }
            else -> {
                val age = DateTime.now().yearInt - data.birthday.year
                msgs.add(Message(idStore.sub(Person.birthday).id, Status.Valid, "Age is $age"))
            }
        }

        // check address fields
        val addressId = idStore.sub(Person.address)
        fun checkAddressField(name: String, lens: Lens<Address, String>) {
            val value = lens.get(data.address)
            if(value.trim().isBlank())
                msgs.add(Message(addressId.sub(lens).id, Status.Invalid, "Please provide a $name"))
            else
                msgs.add(Message(addressId.sub(lens).id, Status.Valid, "Ok"))
        }
        checkAddressField("street", Address.street)
        checkAddressField("house number", Address.number)
        checkAddressField("postalcode", Address.postalCode)
        checkAddressField("city", Address.city)

        // check activities
        if(data.activities.none { it.like })
            msgs.add(Message(idStore.sub(Person.activities).id, Status.Invalid, "Please provide at least one activity"))
        else
            msgs.add(Message(idStore.sub(Person.activities).id, Status.Valid, "You choose ${data.activities.count { it.like }} activities"))

        return msgs
    }
}

Validation im Store

val personStore = object : RootStore<Person>(Person(createUUID())),
                               Validation<Person, Message, String> {
    override val validator = PersonValidator

    val save = handleAndEmit<Unit, Person> { person ->
        // only update the list when new person is valid
        if(validate(person, "add")) {
            offer(person)
            Person(createUUID())
        } else person
    }
} 

Resultat

Routing

Hash-based Routing 

// Erzeugung eines neuen hashbasierten Routers
val router = router(mapOf("page" to Pages.home))

val navigation = html {
    ul("navbar-nav mr-auto") {
        li("btn nav-item") {
            a("nav-link") { // einzelner Navigationslink
                text("Home")
                router.navTo <= clicks.map { mapOf("page" to "Home") }
            }
        }
        li("btn nav-item") {
            a("nav-link") {
                text("Show")
                router.navTo <= clicks.map { mapOf("page" to "Show", "extra" to "extra text") }
            }
        }
        ...
    }
}

Parameter & Deep Links

Remote Calls

AJAX-Request

val userStore = object : RootStore<String>("") {

    val users = remote("https://reqres.in/api/users").acceptJson()

    val loadAllUsers = apply<Unit, String> {
        users.get()
            .onErrorLog()
            .body()
    } andThen update

    val saveUserWithName = apply { s: String ->
        users.post(
            body = """
              {
                "name": "$s",
                "job": "programmer"
              }""".trimIndent())
            .onErrorLog()
            .body()
    } andThen update
}
button("btn btn-primary") {
	text("Load all users")
	userStore.loadAllUsers <= clicks
}

input("form-control", id = "save-user") {
	placeholder = const("Enter new user name")
	userStore.saveUserWithName <= changes.values()
}

Lernkurve

Leichter Einstieg für Java und JavaScript-Entwickler

Focus der Sprache auf Effizienz und Stabilität

Ausführliche Dokumentation

 

Beispiele

Community

Stackoverflow Trend

Trend on StackOverflow