Wrapping One's Head Around the Basics

Async stuff can be very tricky for a beginner. Let's start from "synchronous" code-- ie, the basic, default way that Javascript is read on a computer.

function otherFunct(){
  console.log("we are in another function")
  console.log("the function is doing some stuff")
}
console.log("start")
otherFunct();
console.log("end")

This logs in a sequential way-- the code runs synchronously. In the console, this would read:

  • start
  • we are in another function
  • the function is doing some stuff
  • end

Let's compare this to a basic piece of code that runs asynchronously.

//async example
console.log("start")

//the following takes a "callback" function
setTimeout(() => {
  //all the code in here will be asynchronous
  console.log("we are in the timeout")
}, 2000)

console.log("end")

The code does not stop at the timeout: the console would read

  • start
  • end
  • we are in the timeout

In the normal "callstack" model, this would not make sense. Line by line, the code is executed, right? So we'd wait out two seconds in the middle and the console would read like the code itself? Not in Javascript, in this case, because Javascript interacts with the browser. While it is a single-threaded language, the browser handles code separately from the callstack, using something called Web APIs.

The async function (in this case, setTimeout) is passed to the web API. Then it is left for the browser to handle while the rest of the callstack runs. When the timeout completes, it is sent back to the callstack. (Other things that get passed to the web API include onClick and other interactions.

Async calls like setTImeout, setInterval, and fetch have a pattern of utilizing callback functions. But not all callbacks are async.

Before he went into that, I had to finally figure out what 'callback' meant. It's simple. A callback function is a function that is passed to another function as a parameter. For instance, function print(callback){callback()};. Then when it is called print(()=>{console.log("hi there")}).

OK, so back to sync and async: not all callbacks are async. Here's an example of a sync callback:

const beatles = ["john", "paul", "ringo", "george"]
myArray.forEach(beatle => console.log(beatle))

Since the function is passed to forEach as a parameter, it's a callback. But if it was run like the examples above, the entire loop would take place within "start" and "end" logs.

The Old Way: Using Callbacks to Run Async Code

To continue repeating the basics, getting data back from a server always requires a wait time. Here's an example of server-esque code that does not work:

function loginUser(email, password){
  setTimeout(()=>{
    return({userEmail: email});
  }, 2000)
}
console.log("start")
const user = loginUser("joe@me.com", 12345)
console.log(user)
console.log("end")

This will return

  • start
  • undefined
  • end

Why? Because the console.log(user) is taking whatever it gets from the function call and then moving on. So, during the timeout, the data for userEmail is "undefined".

So how do we get around the undefined return? We pass a callback to the loginUser function.

console.log('start')

//adding callback function as a parameter
function loginUser(email, password, callback) {
  setTimeout(()=>{
    //passing user email as a parameter of the callback
    callback({userEmail: email});
  }, 1500)
}
//now passing the callback as our third parameter
const user = loginUser("joe@me.com", "12324", user => console.log(user))
console.log("end")

This returns

  • start
  • end
  • (waits 1.5 seconds) "{userEmail: "joe@me.com"}"

This was hard to wrap my mind around. Here's what I think is going on. The callback is triggered in the loginUser function. It's defined when the loginUser function is called. So it's kind of a reverse flow. When we define the callback in the loginUser function, we tell it to take in user as a parameter. So when it's triggered in the code above it, it takes the "user" parameter to be `{userEmail: email}`. Then it console logs it.

So when loginUser is defined, the callback is run. And when loginUser runs, the callback is defined. (That's what I meant by "reverse flow").

While the console.log(user) in the first example is outside the setTimeout, the callback consolelog is inside of it. So it's consolelogging asynchronously (because setTimeout is async), while the first console.log is doing normal sync flow.

The problem is that this approach doesn't scale. It creates a weird nested structure of callbacks layered on other callbacks that becomes messy fast. They call it "callback hell". One thing we didn't factor into the above examples is that we might get the wrong data. Using the callback system of the earlier example, we'd pass TWO callbacks per async function, for success and failure. Thus our hell doubles. We can make this easier using promises and async/await.

A Simple Promise Example

A Promise is an object that gives us the result of an async operation or a failure of an async operation. It seems like it bundles all the callback hell of the above stuff into one clean, cozy package.

//constructor function that takes in resolve and reject
const promise = new Promise((resolve, reject) => {
//reminder that timeout only simulates talking to servers
//(you'd never know how long it'd actually take)
  setTimeout(()=>{
    console.log("got the user")
    resolve({user: "joe"})
  }, 2000)
})
//this creates the promise, now we must consume it
//instead of crazy nested callbacks, all we need is a .then()
promise.then(user => console.log(user))

This returns

  • (wait two seconds)
  • "got the user"
  • {user: "joe"}

Adding error handling:

const promise = new Promise((resolve, reject) => {
  setTimeout(()=>{
    console.log("got the user")
//best practice with error stuff is to create new Error object
//comes stock with JS
    reject(new Error("this is an error message"))
  }, 2000)
})
//.then handles the result, .catch handles the error
promise.then(user => console.log(user)).catch(err => console.log(err))

How the success and failure flows interact appears to be:

Resolve => Result => .then()

Reject => Error => .catch()

Refactoring the Callback Example

console.log('start')

function loginUser(email, password) {
  //pass the whole setTimeout within the Promise's callback
  return new Promise((resolve, reject)=>{
    setTimeout(()=>{
//instead of passing in the callback we made in the last one,
//we use the prebuilt 'resolve' callback
    resolve({userEmail: email});
  }, 1500)})
  
}
//now no need for a third parameter
const user = loginUser("joe@me.com", "12324")
.then(user => console.log(user))
console.log("end")

This returns:

  • start
  • end
  • {userEmail: "joe@me.com"}

Note: A lot of the time when you make calls to APIs, you don't need to write out the Promise stuff, as they automatically send you the data in the form of a Promise.

The benefit here is that .then() and .catch() statements strung together are much prettier than "callback hell".

It can get prettier, though-- using async and await statements we can make our code look like sync code.

//after defining the loginUser() function...
function loginUser(email, password) {
  return new Promise((resolve, reject)=>{
    setTimeout(()=>{
    resolve({userEmail: email});
  }, 1500)})
}
//instead of chaining .thens we can
//just do async/await.
//for error handling, wrap it in try/catch
async function displayUser(){
  try{
  const loggedUser = await loginUser("joe@joe.com", 12324)
  }
  catch(err => console.log("error happened"))
}

displayUser()

And that's it! To be honest, a lot of this stuff was still over my head, but I think it will come in super handy once I jump back into making stuff using these tools.