JavaScript: How (not) to get a value "out of" a callback
Callback functions appear to be a complex topic for people, especially if they are new to JavaScript. I believe however that they are often making it more difficult for themselves than it has to be.
Something is complex when there are many rules and/or exceptions around it.
Remembering all the rules and exceptions can be difficult. If you assume that
there are special rules around callback functions then you perceive them as
more complex or difficult to understand.
In this post I’m showing two common mistakes with callbacks and try to show that callback functions are functions like any other with no extra rules. Only a basic understanding of functions is required to understand callbacks, although we will briefly touch on asynchronous code at the end.
Function basics #
Just like in mathematics, a function processes some input and returns a result (output). Here is a simple function that adds two numbers:
function add(a, b) {
return a + b;
}
The function has two parameters, a
and b
and returns their sum. When we
call the function as
var result = add(1,2);
// result = 3
we are supplying two arguments, which are assigned to each parameter respectively, and store the function’s return value in a variable.
However, functions do not have to accept input or return a result, they can also “just do” something:
// No return value
function logWithTime(message) {
console.log(Date.now() + " " + message);
}
logWithTime("Hello World!");
// No input or return value
function warn() {
alert("This is a warning!");
}
warn();
While both examples have a visible outcome, they are not return
ing a
value.
Who is the caller? #
Note that in all examples so far, we directly called the functions, which allowed us to pass arguments (if they accepted them) and do something with their return values (if they returned something).
Most functions however are not directly called by us but by other functions:
function stringRepeat(str, count) {
var result = "";
while (count > 0) {
result += str;
count--;
}
return result;
}
function leftPad(str, width, fillCharacter) {
if (str.length >= width) {
return str;
}
return stringRepeat(fillCharacter, width - str.length) + str;
}
var result = leftPad("foo", 5, "x");
// result = "xxfoo"
Here we are only calling leftPad
directly, which in turn calls
stringRepeat
. This makes leftPad
the caller of stringRepeat
, which
means it controls which arguments to pass and what to do with the return value,
not us.
Execution flow #
This might be obvious, but just to reaffirm: Statements are evaluated in the order they are written in source code.
If two function calls follow each other, the second one will only be executed after the first function call returned:
foo();
bar();
bar
is only called after foo
returned, which implies that all statements
inside foo
are evaluated before bar
is called.
What are callbacks? #
Simply put a callback is a function that is passed as argument to another function. Why is that useful? Lets look at the following example:
function allUpper(array) {
var result = [];
for (var i = 0; i < array.length; i++) {
result[i] = array[i].toUpperCase();
}
return result;
}
function allLower(array) {
var result = [];
for (var i = 0; i < array.length; i++) {
result[i] = array[i].toLowerCase();
}
return result;
}
allUpper(['Foo', 'Bar']); // ['FOO', 'BAR']
allLower(['Foo', 'Bar']); // ['foo', 'bar']
Both functions do basically the same thing: They process each element in an array and return a new array. It would be great if we can somehow separate the result array creation from the element processing:
function map(array, processElement) {
var result = [];
for (var i = 0; i < array.length; i++) {
result[i] = processElement(array[i]);
}
return result;
}
This function has two parameters: The first is expected to be an array and the second one is expected to be a function. That function is passed a single array element and should return the processed result.
We can use this function like so:
function toUpper(item) {
return item.toUpperCase();
}
function toLower(item) {
return item.toLowerCase();
}
map(['Foo', 'Bar'], toUpper); // ['FOO', 'BAR']
map(['Foo', 'Bar'], toLower); // ['foo', 'bar']
Here toUpper
and toLower
are used as callbacks for map
.
(Processing each element in an array and producing a new array is such a common
operation that it is built into the language:
Array#map
).
IMPORTANT: The caller of the callback is usually the function we pass the callback to, but never us.
Misconception 1: Returning from the callback #
// BROKEN!
var someData = someFunction(function(someResult) {
return someResult;
});
console.log(someData); // expected to be equal to `someResult`
The first misconception is that returning from the callback will somehow return from the function the callback is passed to. Maybe it’s because the function definition is inlined with the function call and thus the callback somehow appears to be “inside” the other function.
However, the code behaves exactly the same if we defined the callback upfront:
// BROKEN!
function doSomething(someResult) {
return someResult;
}
var someData = someFunction(doSomething);
console.log(someData); // expected to be `someResult`
Even without knowing anything about someFunction
, we can make the following
observations:
-
We are calling
someFunction
, therefore we are assigningsomeFunction
’s return value tosomeData
. -
someFunction
may or may not calldoSomething
, i.e.someFunction
is the caller ofdoSomething
, therefore it may or may not do something withdoSomething
’s return value.
This is the key point: We do not call doSomething
, therefore we
don’t have any control over what happens to its return value.
It could indeed be the case that someFunction
returns doSomething
’s return
value, e.g.
function someFunction(callback) {
return callback(42);
}
and the example above would work.
But it also may not:
function someFunction(callback) {
callback(42);
}
We cannot know just by looking at the initial example. What the return value of
someFunction
is really depends on what the function does (which is usually
described in its documentation) and is not necessarily related to the return
value of the callback.
Misconception 2: Assigning to an “outer” variable #
// BROKEN!
var someData;
someFunction(function(someResult) {
someData = someResult;
});
console.log(someData);
In this case the callback explicitly assigns the value it gets passed to another variable, defined outside of it.
The same code with a separate function declaration:
// BROKEN!
var someData;
function doSomething(someResult) {
someData = someResult;
}
someFunction(doSomething);
console.log(someData);
Without knowing what someFunction
does, we can state the following:
If someFunction
calls doSomething
before someFunction
returns,
someResult
is assigned to someData
before console.log
is called. In other
words, console.log(someData)
will log the same value as someResult
.
How can a function call another function after it returns? That’s were asynchronous code execution comes into play.
A short introduction to asynchronous code #
When we talk about asynchronous code, we usually refer to a function that will be executed some time in the future, either in response to some event or because a specific amount of time has passed.
A simple way to schedule a function to run in the future is via
setTimeout
:
setTimeout(function() { console.log('hello'); }, 1000);
This schedules the passed function to be called “later” and after at least one second.
The second important aspect about asynchronous code execution is that scheduled functions are only executed if the runtime isn’t busy executing other code.
Consider the following example:
setTimeout(function() { console.log('world'); }, 0);
console.log('hello');
setTimeout
schedules a function to be called later, but as soon as possible
(the timeout is 0
). However the callback won’t be executed immediately
because the runtime is still busy evaluating the function call that follows the
setTimeout
call, console.log('hello')
. Only after that call the runtime
is free to process any scheduled functions.
This is also called run-to-completion.
“Wait!” you might think. “Didn’t you say earlier that statements are
executed in the order they are written? Why is the function passed to
setTimeout
not called before the second console.log
?”
As in the previous misconception: We are not calling the function passed to
setTimeout
. We merely pass the function object itself. setTimeout
isn’t
calling the function either. It’s telling the runtime to call this function
some time in the future.
Back to the example:
// BROKEN!
var someData;
function doSomething(someResult) {
someData = someResult;
}
someFunction(doSomething);
console.log(someData);
If someFunction
doesn’t call doSomething
immediately but schedules it to
run some time in the future, console.log(someData)
is guaranteed to execute
before the assignment someData = someResult
happens.
Here again, the documentation of someFunction
should describe how the
callback is used and whether the function performs asynchronous computation or
not.
Conclusion #
A simple thought experiment to spot issues with callbacks is the following:
Consider one of the examples again:
// BROKEN!
var someData;
someFunction(function(someResult) {
someData = someResult;
});
console.log(someData);
We are trying to get the result “out” that someFunction
passed to the
callback. If someFunction
returned the result instead we could simplify
our code to:
var someData = someFunction();
console.log(someData);
But the very fact that someFunction
requires a callback instead of
directly returning the result is already an indication that assigning the
result to someData
won’t work!
So, what should you do?
Instead of trying to get a value to the code that needs it, move the code to where the value is.
That means moving the code inside the callback function or into another function that is called from the callback.
For example:
// BROKEN!
var someData;
function doSomething(someResult) {
someData = someResult;
}
someFunction(doSomething);
// Code that needs someData
console.log(someData);
// Other code
becomes
// Working
function doSomething(someData) {
// Code that needs someData
console.log(someData);
}
someFunction(doSomething);
// Other code
or
// Working
function doSomethingElse(someData) {
// Code that needs someData
console.log(someData);
}
function doSomething(someResult) {
// do stuff
doSomethingElse(someResult);
}
someFunction(doSomething);
// Other code