Intro
Last time we talked about Callbacks - a pattern that is deceptively easy to understand. The concept which we will discuss today is a next step of evolution and naturally extends the callbacks' capabilities. It also brings us an interesting solution for async programming and most importantly - it shifts our mindset and forces us to look at things from the different perspective. This time I want to provide you a comprehensive explanation on what are thunks and how it can help to organize our code better.
What the hell is that?
For real though, I wish I knew why somebody came out with this name. But jokes aside, thunks are the thing that at some point made me wondering how I had got so far with JavaScript without even knowing how powerful it can be. From a synchronous perspective, thunk is essentially a function that is ready to give you some value back and requires no additional input. As simple as that. Many of you working with React probably know an awesome and plain simple library called redux-thunk which as name suggests is based on thunks. But more on that later. For now let's take a look at a simple example of a synchronous thunk:
function superCalculation() {
return 9999 + 9999
}
const outFirstThunk = function () {
return superCalculation()
}
const sum = thunk() // 19998
Here we have a thunk called ourFirstThunk
which value is a function and when it gets called it will always return us the same value - the result of out superCalculation
.
The part we care about
The important part is that this thunk has become a wrapper around some particular state. In this case it is a result of a potentially expensive operation. Imagine yourself shooting a beautiful moment on vintage film. The film itself is your thunk and the captured moment is the wrapped state. We can now pass this "film" around our app and when we want to extract that state, we simply "develop the film" by calling the thunk and get the value back. Instead of working with the state itself, we are passing a representation of the value. Pattern allows us to conveniently hide the details of underlying computation and provides a common interface. We also managed to delay the calculation until we really need it and it is possible now to inject this operation into different parts of our code. This is what is also called lazy thunk.
Going async
Things start to become quite intriguing when you are thinking about async applications. So how would you possibly describe an async thunk? For the most part it is the same. It's a function which doesn't need any arguments to make its job except for a callback. Interestingly enough despite all of its flaws, callback pattern has managed to find its use here. The standard synchronous implementation does not take time factor into account and we already saw that callbacks are pretty capable of handling "future value processing". Why not use it here as well? Let's extend our previous example to an asynchronous thunk:
function superCalculationAsync (callback) {
setTimeout(() => {
callback(9999 + 9999)
}, 1000)
}
const thunk = function (callback) {
superCalculationAsync(callback)
}
thunk((result) => {
console.log(result) // 19998
})
We now have an superCalculationAsync
function which fakes an asynchronous behaviour by using setTimeout
utility. We then create a thunk
which is a function accepting a callback. This callback is passed to superCalculationAsync
function to handle the result of the operation. The overall concept stays the same, except for callback coming into play to help us handle things. Still we end up with a handy container which we can use anywhere in our app as long as we pass the callback.
Lazy vs Eager
We managed to convert our synchronous thunk into an asynchronous one. You will notice that our superCalculationAsync
itself is not executed right away. This is a lazy thunk. Until the callback is provided, no calculations will fire. Let's try to toy with this example a little bit more and think of the way to rewrite it to eager thunk - the one which will try to run calculations in advance and attempt to give you result back immediately.
const thunk = (function () {
let thunkResult;
let handleResult;
superCalculationAsync(function (result) {
if (handleResult) {
handleResult(thunkResult) // result is not ready
} else {
thunkResult = result // result is ready
}
})
return function runThunk (callback) {
if (thunkResult) {
callback(thunkResult) // result is ready
} else {
handleResult = callback // result is not ready
}
}
})()
While developing an eager thunk you stumble upon two possible cases that you need to handle. The first case is when thunk is called after the inner operation is completed and we can safely return the result. This is the easy part and it is no different to what we have been doing so far. The second case is something to think about - the thunk is called, but the operation is still going. We have to bridge those two branches of our program somehow. The provided solution is by no means the most performant and elegant one but it gets work done. Here we ended up with two if
statements that mirror each other. We call the user's callback with a result of an underlying computation if it is already done. If not, we are injecting the provided callback directly. Client's code will not even know that the thunk might take time to complete.
Power comes with abstraction
Here is the point - we could rewrite our synchronous example with a callback and then treat both an async and sync thunk uniformly. By doing that we are effectively freeing ourselves from dealing with a time factor in our code by having this kind of normalization. We don't have to know or care about the how a value is delivered to us. The first time we call our thunk and pass a callback it might do significant work to get an expected response. It could be an AJAX request, a CPU intensive task or any other crazy stuff which can take a while. But the second time we call it, it might decide to memoize the return value and give it to us right away. A client code using our thunks doesn't need to have any concerns on internal implementation as long as it has the way to work with both synchronous and asynchronous code in the same manner. This is a big step forward. We have produced a wrapper around data that is time independent. And we know that time might be the most complex thing to manage in our applications.
Real world example
I have already mentioned redux-thunk - a library that is recommended to use for handling side effects in redux app according by redux maintainers themselves. It provides us a middleware which expects a thunk or a simple action object and handles them accordingly. It is so dead simple, that main function which creates a middleware is just 9 lines of code.
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => (next) => (action) => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
The code is pretty straightforward and most likely doesn't need any explanation at all. This is conceptually the same thunk we were talking about above. The only difference comes with a few extra arguments that are passed into our thunk - dispatch
and getState
with dispatch
fulfilling a role of a callback.
Simplicity
The great thing about thunks is that this is just a pure JavaScript code. No libraries or frameworks involved. By adopting a different way of thinking, we managed to eliminate a confusing and difficult to handle thing called time. Let it sink for a moment. The mental overhead is gone and replaced with a common interface which represents our value. As a bonus, we are capable of reusing these representations across our code without any problems. But there is a revelation to be made.
The dreaded Inversion of Control issue
I will make this statement right away - thunks were not created to address Inversion Of Control issue. This is not a silver bullet in the world of async programming. In the example above, redux-thunk library has no way to ensure that their dispatch
function will be called appropriately. The same is true for our examples. What thunks are effectively doing is that they are laying a foundation for Promises. If you are familiar with promises, and I am pretty sure most of you are, you can notice that thunks are essentially Promises without a fancy API. Yes, we are getting benefits of uniform treatment, reusability and a nice wrapper which encapsulates the details of our computations, but Inversion Of Control issue is still to be solved. Also, because thunks are still using callbacks under the hood, you could easily end up with something that is very similar to Callback Hell. If we try to express several operations that have temporal dependencies between each other, that would become clear. Let's assume that we have a makeThunk
utility which accepts a function and a list of parameters which are passed to wrapped to it. For the sake of simplicity I will not provide any implementation details on it, you can find plenty of those on the internet.
const readFirst = makeThunk(readFile, 'first file');
const readSecond = makeThunk(readFile, 'second file');
const readThird = makeThunk(readFile, 'third file');
readFirst((firstFileContents) => {
console.log('first file contents', firstFileContents);
readSecond((secondFileContents) => {
console.log('second file contents', secondFileContents)
readThird((thirdFileContents) => {
console.log('third file contents', thirdFileContents)
})
})
})
We first precreate three thunks for later use. It is important to understand that readFile
is not executed until we pass the callback. On the next lines, we nest thunks executions to get the right order of operations. The rule temporal dependency === nesting holds true here as well.
Outro
Thunks went a long way to improve our JavaScript code. This pattern brings couple of crucial benefits comparing to callbacks and still manages to be lightweight and simple. And the best part is that it is all possible with just the functions' manipulations. As we saw in redux-thunk library example, thunks make handling side effects in our Redux a childsplay in just 9 lines of code. After some practice you could imagine that capabilities of this pattern extend far beyond the scope of just React & Redux apps. Thunks ideologically precede the Promise pattern and these two are much similar. Although thunks didn't manage to solve the Inversion Of Control issue, we will see how the conceptual core of this pattern with an addition of new API finally succeeds. Thank you for a read, keep your eyes on updates and next time we will talk about Promises.
Top comments (0)