Definition
To reiterate:
The word "thunk" is a programming term that means "a piece of code that does some delayed work". Rather than execute some logic now, we can write a function body or code that can be used to perform the work later.
https://redux.js.org/usage/writing-logic-thunks
While a Redux thunk is a function, dispatched as an action to a Redux store and processed by the redux-thunk middleware, a Kotlin Bloc
thunk is not dispatched as an action but triggered the same way a reducer is triggered, by reacting to an Action
that was sent to the Bloc
. On top of that it's also:
- a suspending function
- actions are dispatched to the "next" thunk or reducer in the execution chain
Context
A thunk is called with a ThunkContext
as receiver. The context is giving access to the current State
, the Action
that triggered the thunk's execution, a Dispatcher
and a function to "reduce" state directly:
public data class ThunkContext<State, Action>(
val getState: GetState<State>,
val action: Action,
val dispatch: Dispatcher<Action>,
val reduce: (proposal: Proposal) -> Unit
)
A typical thunk would evaluate the action, run some asynchronous operation and dispatch actions to be processed by other thunks or reducers:
thunk {
if (action == Load) {
dispatch(Loading)
val books = repository.load()
dispatch(LoadComplete(books))
} else {
// without this no action would reach the reducers
// below because this is a catch-all thunk
dispatch(action)
}
}
reduce<Loading> {
state.copy(loading = true)
}
reduce<LoadComplete> {
state.copy(loading = false, books = action.books)
}
The catch-all thunk { }
needs to call dispatch(action)
explicitly or no reducers will be executed (see Execution).
In this case using a single action thunk would be simpler though and you'd write:
thunk<Load> {
dispatch(Loading)
val books = repository.load()
dispatch(LoadComplete(books))
}
This doesn't require to call dispatch(action)
explicitly since it only catches a single action (Load
) and then dispatches its own actions.
There are extension functions to launch Coroutines
from a thunk (see Coroutine Launcher).
reduce()
Thunks are meant to run asynchronous code and reducers are meant to reduce state. In many cases however the reducers are very simple functions. In the example above we need to add a dedicated action Loading
, dispatch that action in the thunk in order for a reducer to reduce the current state to one that indicates loading. While that "separation of concerns" is useful in many cases, it adds a good amount of boilerplate code for simple cases like the one we have here. To simplify this we can use the ThunkContext.reduce
function:
thunk<Load> {
reduce(getState().copy(loading = true))
val books = repository.load()
reduce(getState().copy(loading = false, books = books))
}
Reducers are executed in the order they are added to the reducer queue (see Reducer Concurrency). When reduce()
is called from a thunk or an initializer, that reducer function is also added to the queue to guarantee the correct order of execution but the reduce call itself will suspend till the queued reducer was executed. This guarantees that this always succeeds:
thunk {
reduce(newState) // <- suspends till the state reduction is done
assertEquals(getState(), newState) // <- assertion is always true
}
dispatch()
When dispatching actions to reducers, the dispatch and the subsequent state reduction will happen in a blocking / suspending manner. Similar to reduce()
, a call to dispatch()
will suspend and continue once the reducer is done. This is NOT true if the action was dispatched to another thunk though, only actions dispatched to reducers are processed synchronously:
thunk {
val state = getState()
dispatch(Increment) // <- suspends till the reducer has run
assertEquals(getState(), state + 1) // <- assertion is always true
}
reduce<Increment> {
state + 1
}
Execution
A lot of what was said in the reducer documentation applies to thunks
too but there are important differences. Here are the rules:
- If there's more than one reducer matching an action, only the first one will be executed (can't reduce the same state twice).
Rule 1: for thunks, every matching thunk will be executed. - Reducers are executed by matching action, the order of declaration only matters when there's more than one matching reducer.
Rule 2: for thunks, the order of declaration is crucial in determining which one executes first especially because every matching thunk will be executed (rule 1). - Rule 3: when a thunk dispatches an action, it's dispatched to all matching thunks declared after the dispatching thunk (or we would end up with an endless loop). If no matching thunk is found, the action is dispatched to the first matching reducer.
Example 1
An example of rule 1 and 2:
thunk<Increment> {
println("thunk 1")
}
thunk<Increment> {
println("thunk 2")
}
thunk {
println("thunk 3")
}
reduce {
println("reducer")
state
}
Above thunks will all be executed in the order of their declaration (sequentially). The output will be:
thunk 1
thunk 2
thunk 3
The reducer won't be executed as none of the thunks dispatches any action.
Example 2
Try to guess what happens when the first thunk dispatches an Increment
action (all three rules apply):
thunk<Increment> {
println("thunk 1")
dispatch(Increment)
}
thunk<Increment> {
println("thunk 2")
}
thunk {
println("thunk 3")
}
reduce {
println("reducer")
state
}
The output will be:
thunk 1
thunk 2
thunk 3
thunk 2
thunk 3
- the original
Increment
action is dispatched to the first thunk
-> outputs:thunk 1
- the first thunk dispatches a second
Increment
action to all matching thunks declared after the dispatching thunk (rule 3) - thunk 2 is the first to process the second
Increment
action
-> outputs:thunk 2
- thunk 3 is next in line to process the second
Increment
action
-> outputs:thunk 3
- the original
Increment
action is dispatched to the second thunk
-> outputs:thunk 2
- the original
Increment
action is dispatched to the third thunk
-> outputs:thunk 3
- nothing reaches the reducer
While this sounds complicated, the rules are pretty straight forward. Examples like the one above are also rather academic and have little relevance for real applications.
Example 3
Here's a more realistic example:
thunk<LoadUser> {
dispatch(UserLoading)
val result = repo.loadUser()
dispatch(UserLoaded(result))
}
thunk<UserLoading> {
dispatch(AccountLoading)
val result = repo.loadAccount()
dispatch(AccountLoaded(result))
}
reduce<UserLoading> {
// this won't be executed because the second thunk captures the UserLoading action
}
reduce<AccountLoading> {
state.copy(loading = true)
}
reduce<UserLoaded> {
state.copy(user = action.result)
}
reduce<AccountLoaded> {
state.copy(loading = false, account = action.result)
}