What a Thunk Actually Is

A thunk is a function that already has everything it needs to hand you a value, sync or async, and why that removes time as a factor in how you use it.

September 6, 20265 min read9 / 17

Here's the actual idea this post teaches, in one sentence: a thunk is a function that already has everything it needs to hand you a value, so calling it never has to care whether that value was ready before you asked or only shows up later.

A Function That Already Has Its Answer

Start with the plainest possible version, no async involved at all:

JavaScript
function add(x, y) { return x + y; } function makeAddThunk() { return function () { return add(10, 15); }; } const thunk = makeAddThunk(); console.log(thunk()); // 25 console.log(thunk()); // 25, every single time

Look closely at those two functions, because the difference between them is the entire idea this post is about.

add is a normal function. It needs x and y handed to it every time, and the answer depends on what you pass in. add(10, 15) and add(3, 4) give different answers, because you're the one choosing the numbers.

thunk takes no arguments at all. It doesn't need any, because 10 and 15 are already locked inside it, from the moment makeAddThunk() ran. Call it once, call it a hundred times, you always get 25 back. Nothing you do can change the answer, because you're not giving it anything to work with anymore.

addthunk
Takes arguments?Yes, every timeNo, none
Can the answer change?Yes, depends what you pass inNo, always 25
What it really isA calculationA value, already decided

That's the whole trick. thunk is still just a plain function underneath, nothing new was added to JavaScript to make it. It's only being used differently: as a small box holding one value, instead of a machine you feed new numbers into. Calling it doesn't calculate anything new, it just opens the box and hands you what's already inside.

The Same Idea, When the Answer Isn't Ready Yet

Now stretch that same idea into async code. An async thunk still needs no arguments to do its job, except one: a callback to hand the value to once it exists.

JavaScript
function fakeFetchStat(name, cb) { const delay = 300 + Math.random() * 700; setTimeout(() => cb(name, `${name} data ready`), delay); } function fetchStatThunk(name) { return function (cb) { fakeFetchStat(name, (_, data) => cb(data)); }; } const inventoryThunk = fetchStatThunk('inventory'); inventoryThunk((data) => console.log(data));

fetchStatThunk('inventory') doesn't fetch anything yet. It hands back a small function, inventoryThunk, that already knows which stat it's responsible for. Call inventoryThunk with a callback, and eventually that callback gets the data.

Why This Actually Matters

Compare the two thunks above. The first one gives you a value back immediately. The second one makes you wait.

From the outside, using them looks almost the same. You call the thunk. You get a value out. Whether that value was sitting there already or took 700 milliseconds to show up was never something you had to think about while writing the call.

That's the real payoff. Time is the single most complicated thing to reason about in any program. A thunk removes it from the picture entirely. You use a value the same way whether it arrived instantly or arrived later.

This is also the exact idea a promise is built on. A promise is a fancier wrapper around the same thing: a container around a value that doesn't care when the value actually shows up.

Thunks are what promises look like with all the extra API stripped away.

One Tool for Making Any Thunk

Writing a custom thunk-making function for every single value gets old fast. Here's a generalized version that works for any function and any arguments:

JavaScript
function makeThunk(fn, ...args) { return function (cb) { fn(...args, cb); }; } const shippingThunk = makeThunk(fakeFetchStat, 'shipping'); shippingThunk((name, data) => console.log(data));

makeThunk takes a function and whatever arguments it needs, minus the callback, and hands back a thunk that supplies those arguments automatically. Whatever you eventually call it with becomes the callback.

What a Thunk Doesn't Fix

Thunks don't solve the trust problem. Nothing here checks whether fetchStatThunk's callback fires exactly once, or fires at all. That's still an open question, and thunks were never trying to answer it.

What thunks do give you is smaller, but still real: one consistent shape for "a value, eventually," whether that value is ready now or later. Promises use that exact shape, and finally add the missing trust guarantees on top of it.

The Constraint That Sets Up the Next Post

One more thing worth noticing. Some thunks can be built ahead of time. Others can't.

JavaScript
function getBasePrice(sku, cb) { setTimeout(() => cb(120), 300); } function getShippingCost(sku, cb) { setTimeout(() => cb(15), 300); } function getFinalTotal(amount, cb) { setTimeout(() => cb(amount * 1.08), 300); } const baseThunk = makeThunk(getBasePrice, 'sofa-142'); const shippingCostThunk = makeThunk(getShippingCost, 'sofa-142'); baseThunk((base) => { shippingCostThunk((shipping) => { // Only now do both numbers exist, so only now can this thunk be built. const totalThunk = makeThunk(getFinalTotal, base + shipping); totalThunk((total) => console.log(`Total: $${total.toFixed(2)}`)); }); });
baseThunk and shippingCostThunk get built right away
Both only need a fixed SKU, which you already have.
totalThunk can't exist yet
It needs base + shipping added together, and neither number exists until both thunks call back.
totalThunk gets built only once both arrive
It's created fresh, inside the callback, the moment the numbers it depends on actually exist.

A thunk can only be built once everything it needs is already known. That's a small, easy-to-miss detail right now.

It's also exactly what separates the two different flavors of thunk this series builds on next: one that waits to do any work until you ask for the value, and one that starts working the instant it's created.