Hello all :)
In this article I will show you how to build a web application with Kotlin, KVision and Redux. You could know Redux quite well, but you certainly do not know how easy it can be used in a web application created in Kotlin.
KVision is an open source web framework created for Kotlin/JS. It allows developers to build modern web applications with the Kotlin language. You can find my short introduction to KVision in this article.
Redux is a popular predictable state container for JavaScript apps. Support for Redux, in the form of kvision-redux
module, was added to KVision just a week ago.
In the first part of the article we will design and implement the Redux model for our application. We will implement the state class, the actions classes and the reducer function. In the second part we will create the user interface.
You can find the complete application and all the sources in the kvision-examples GitHub repository.
The specification
We want to create a simple application with the following features:
- It shows a list of Pokémons with a name and a picture.
- It uses PokéAPI RESTful services.
- It allows to search for a Pokémon by name.
- It displays the results in the responsive grid with a pagination.
This application is loosely based on this project, from the official React examples.
The template
To start we will clone the template
project from the kvision-examples GitHub repository. To work with Redux we need to add kvision-redux
module. These are the dependencies in our build.gradle
file:
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-js:${kotlinVersion}"
compile "pl.treksoft:kvision:${kvisionVersion}"
compile "pl.treksoft:kvision-bootstrap:${kvisionVersion}"
compile "pl.treksoft:kvision-i18n:${kvisionVersion}"
compile "pl.treksoft:kvision-redux:${kvisionVersion}"
}
The state
The state of the application will be stored in the Pokedex
class. It contains the information about the downloading process (which can possibly end with an error), the list of all downloaded Pokémon objects, a list of Pokémons visible on the current page, the current search string, the current page number and the number of available pages. KVision requires the state object to be serializable with kotlinx.serialization library, so we need a @Serializable
annotations on the Pokedex
and the Pokemon
classes.
@Serializable
data class Pokemon(val name: String, val url: String)
@Serializable
data class Pokedex(
val downloading: Boolean,
val errorMessage: String?,
val pokemons: List<Pokemon>,
val visiblePokemons: List<Pokemon>,
val searchString: String?,
val pageNumber: Int,
val numberOfPages: Int
)
The actions
The actions in Redux correspond to events that can change the state of the application. We will implement the actions as sealed class hierarchy inherited from redux.RAction
class.
sealed class PokeAction : RAction {
object StartDownload : PokeAction()
object DownloadOk : PokeAction()
data class DownloadError(val errorMessage: String) : PokeAction()
data class SetPokemonList(val pokemons: List<Pokemon>) : PokeAction()
data class SetSearchString(val searchString: String?) : PokeAction()
object NextPage : PokeAction()
object PrevPage : PokeAction()
}
The actions with parameters are implemented as data classes and the others are just simple objects.
The reducer function
The reducer function describes how an action transforms the current state into the new state. It's the main place for the application logic.
To reuse some code we will create two helper extension functions. The first one filters the list of Pokemon
objects with the given search string and returns original list if the string is null
.
fun List<Pokemon>.filterBySearchString(searchString: String?): List<Pokemon> {
return searchString?.let { search ->
this.filter {
it.name.toLowerCase().contains(search.toLowerCase())
}
} ?: this
}
The second function cuts a sublist from the original list based on the given page number. It uses the MAX_ON_PAGE
constant, which is defined as 12, and do some very simple math.
fun List<Pokemon>.subListByPageNumber(pageNumber: Int): List<Pokemon> {
return this.subList((pageNumber) * MAX_ON_PAGE, (pageNumber + 1) * MAX_ON_PAGE)
}
Writing the reducer function is now quite easy. The first four actions just change the state properties. Other actions are a bit more complicated - they calculate the new list of visible Pokémons and we use the helper functions defined above for that purpose. We can implement the whole pokedexReducer
function as follows:
fun pokedexReducer(state: Pokedex, action: PokeAction): Pokedex = when (action) {
is PokeAction.StartDownload -> state.copy(downloading = true)
is PokeAction.DownloadOk -> state.copy(downloading = false)
is PokeAction.DownloadError -> state.copy(downloading = false, errorMessage = action.errorMessage)
is PokeAction.SetPokemonList -> state.copy(pokemons = action.pokemons)
is PokeAction.SetSearchString -> {
val filteredPokemon = state.pokemons.filterBySearchString(action.searchString)
val visiblePokemons = filteredPokemon.take(MAX_ON_PAGE)
state.copy(
visiblePokemons = visiblePokemons,
searchString = action.searchString,
pageNumber = 0,
numberOfPages = ((filteredPokemon.size - 1) / MAX_ON_PAGE) + 1
)
}
is PokeAction.NextPage -> if (state.pageNumber < state.numberOfPages - 1) {
val newPageNumber = state.pageNumber + 1
val visiblePokemons = state.pokemons.filterBySearchString(state.searchString).subListByPageNumber(newPageNumber)
state.copy(visiblePokemons = visiblePokemons, pageNumber = newPageNumber)
} else {
state
}
is PokeAction.PrevPage -> if (state.pageNumber > 0) {
val newPageNumber = state.pageNumber - 1
val visiblePokemons = state.pokemons.filterBySearchString(state.searchString).subListByPageNumber(newPageNumber)
state.copy(visiblePokemons = visiblePokemons, pageNumber = newPageNumber)
} else {
state
}
}
The store
Our data model is now almost complete. All that's left is to create the Redux store with some initial state. We use createReduxStore
function for this.
val store = createReduxStore(::pokedexReducer, Pokedex(false, null, listOf(), listOf(), null, 0, 1))
We can now dispatch actions (or action creator functions) to the store. When the application starts, we have to download all Pokémons data from the PokéAPI servers. We call dispatch
in the following way.
store.dispatch(downloadPokemons())
// ...
private fun downloadPokemons(): ActionCreator<dynamic, Pokedex> {
return { dispatch, _ ->
val restClient = RestClient()
dispatch(PokeAction.StartDownload)
restClient.remoteCall(
"https://pokeapi.co/api/v2/pokemon/", obj { limit = 800 },
deserializer = Pokemon.serializer().list
) {
it.results
}.then { list ->
dispatch(PokeAction.DownloadOk)
dispatch(PokeAction.SetPokemonList(list))
dispatch(PokeAction.SetSearchString(null))
}.catch { e ->
val info = if (!e.message.isNullOrBlank()) { " (${e.message})" } else { "" }
dispatch(PokeAction.DownloadError("Service error!$info"))
}
}
}
The downloadPokemons
method returns ActionCreator function, which uses RestClient
instance to call PokéAPI endpoints, receives the data and finally dispatches SetPokemonList
and SetSearchString
actions. It also handles the possible error in the downloading process.
Other actions are dispatched from the user interface elements, but we will take care of the GUI in the next part of this article. I will post it as soon as it's ready.
For now any feedback is highly appreciated.
Cheers :)
Top comments (0)