JavaScript Foundations
JavaScript is the one language that runs unmodified in every browser on earth, and — via a runtime like Node.js — on servers too. Everything you'll build in this course, no matter which framework or tool sits on top, ultimately compiles down to the small set of ideas in this chapter: values, functions, and a peculiar but learnable way of handling things.
Values and variables #
A variable is a name you give to a value so you can use it later. Modern JavaScript gives you two ways to declare one:
let vs constlet count = 0; // can be reassigned later
count = count + 1;
const title = "Buy milk"; // cannot be reassigned
// title = "Buy eggs"; // this line would throw an error
Default to const. Reach for let only when you know the value needs to
change, such as a loop counter or a running total. You will almost never see var in modern
code — it's scoped to the nearest function rather than the nearest block, so a var
declared inside an if or a for loop "leaks" out and is still visible outside
it, which causes bugs let and const simply don't allow.
Every value has a type. JavaScript's primitive types are small and worth memorizing:
| Type | Example | Notes |
|---|---|---|
string | "todo item" | Text, in single, double, or backtick quotes |
number | 42, 3.14 | No separate integer type |
boolean | true, false | Two values, nothing else |
| undefined | undefined | A variable that's declared but never assigned |
| null | null | An intentional "no value" |
The difference between the two "nothing" values shows up constantly in practice. Reading a property
that's never been set gives you undefined, silently — no error, no warning:
const todo = { id: 1, title: "Buy milk" };
console.log(todo.dueDate); // undefined — this property is never set
console.log(todo.dueDate.getMonth()); // TypeError: Cannot read properties of undefined
Nothing stopped you from writing todo.dueDate.getMonth(); JavaScript only complains the
moment it actually tries to read a property off of undefined. This exact gap
— code that looks fine but crashes on data that happens to be missing — is what
strictNullChecks in Chapter 3 catches before
you ever run the code.
Both values mean "nothing," so it's fair to ask why JavaScript bothers with two. In practice the distinction is about whose decision the absence is:
undefinedmeans "nobody has set this yet" — a property that's never assigned, a function argument the caller didn't pass, a variable declared withletbut not yet initialized. You generally don't writeundefinedyourself; it's what you get by default when something is missing.nullmeans "someone deliberately set this to nothing" — you write it yourself, on purpose, to represent an intentional absence: aselectedTodothat'snullbecause nothing is currently selected, adueDateexplicitly cleared by the user rather than never having had one.
One concrete place this shows up: JSON.stringify() drops properties whose value is
undefined from the output entirely, but keeps a property whose value is null.
If an API needs to tell the difference between "this field is unset" and "this field is
explicitly cleared," null is the only one of the two that survives being sent over the
network. Many teams settle on a house rule — "we use null for intentional absence,
never undefined, in anything we send or store" — specifically to avoid this
ambiguity.
Objects and arrays #
Everything more complex than a primitive is built from two shapes. An object is a bag of named fields; an array is an ordered list.
a todo item and a list of themconst todo = {
id: 1,
title: "Buy milk",
done: false,
};
const todos = [
{ id: 1, title: "Buy milk", done: false },
{ id: 2, title: "Write chapter 2", done: true },
];
console.log(todo.title); // "Buy milk"
console.log(todos[0].title); // "Buy milk" -- arrays are zero-indexed
console.log(todos.length); // 2
Arrays come with built-in helpers that avoid hand-written loops for common tasks:
const titles = todos.map(t => t.title); // ["Buy milk", "Write chapter 2"]
const pending = todos.filter(t => !t.done); // items where done is false
const allDone = todos.every(t => t.done); // false
const anyDone = todos.some(t => t.done); // true
Functions #
A function packages up a piece of logic so you can name it and reuse it. JavaScript has two common syntaxes for writing one:
two ways to write the same function — pick one, not both (declaringaddTodo twice in one file is a SyntaxError)
// option A: function declaration
function addTodo(title) {
return { id: Date.now(), title, done: false };
}
// option B: arrow function assigned to a const — more common in this course
const addTodo = (title) => {
return { id: Date.now(), title, done: false };
};
// a short arrow function can skip the braces and "return"
const isDone = (t) => t.done;
Date.now() returns the current time as a plain number (milliseconds since a fixed
reference point), which is enough to stand in for an id in these early examples — real
applications generate ids more carefully, since two calls close together can collide.
Arrow functions are the default style you'll see in this course, especially as short callbacks
passed to array helpers like map and filter above. They also don't rebind
this, which matters once you start writing component code in later chapters — for
now, just treat them as a terser way to write a function.
Strings that mix in variables use a
template literal
(a backtick-quoted string that embeds expressions with ${...}) rather than concatenation:
const name = "David"; // the value being interpolated
const greeting = `Hello, ${name}! You have ${pending.length} todos left.`;
Each ${...} is an interpolated expression: JavaScript evaluates whatever's inside —
a variable, a property access, even a function call — and drops the result directly into the
string at that position. Above, ${name} and ${pending.length} are each
replaced with their current value when the template literal runs.
Use backticks (`) any time a string needs to include a variable or another
expression. It reads more clearly than "Hello, " + name + "!" and it's the style used
throughout the rest of this course.
Control flow, briefly #
You've likely seen if/else from other languages, and JavaScript's version is
aligned to those "c-like" conventions:
if (pending.length === 0) {
console.log("All caught up");
} else if (pending.length === 1) {
console.log("One thing left");
} else {
console.log(`${pending.length} things left`);
}
for (const t of todos) {
console.log(t.title);
}
Note ===, not ==. The triple-equals form checks value and type together
and avoids a long list of surprising coercion rules that the double-equals form has. Use
=== and !== by default.
JavaScript will silently convert values of different types to compare them — called type coercion — and the rules are not always intuitive:
0 == false; // true -- number coerced to boolean
"" == false; // true -- empty string coerced to boolean
"0" == 0; // true -- string coerced to number
null == undefined; // true -- these two are loosely equal to each other...
null === undefined; // false -- ...but never strictly equal
1 === 1; // true -- same value, same type, no surprises
=== sidesteps all of this by refusing to coerce at all — if the types differ,
the answer is simply false, with no conversion rules to memorize. This matters just as
much outside of ==: any if (someValue) check coerces
someValue to a boolean, and a handful of values are "falsy" (coerce to false)
even though they aren't the boolean false itself: 0, "" (empty
string), null, undefined, NaN, and false itself.
Every other value — including "0" the string, and an empty array or object —
is "truthy." A todo count of 0 being falsy is a classic bug source:
if (todo.notes) silently treats an empty string the same as a missing value, which is
sometimes what you want and sometimes very much not.
Asynchronous code: juggling code #
Everything above behaves the way you'd expect from any language: one line finishes before the next one starts. Networking breaks that assumption. When your code asks a server for data, the answer might take 5 milliseconds or 5 seconds — and JavaScript refuses to freeze the whole program while it waits.
The event loop, conceptually
JavaScript runs on a single thread with one call stack. When you call a function that has to wait on something slow — a network request, a timer, reading a file — that work is handed off, and the stack keeps running your other code. When the slow thing finishes, its callback is placed on a task queue, and the event loop pulls from that queue only once the call stack is empty.
This is why a single-threaded language can still feel responsive: it never blocks on I/O. It just reorders when your callback gets to run.
This diagram simplifies one thing worth knowing about: there are actually two queues, not one.
Promise callbacks (the microtask queue) always run before timer callbacks like
setTimeout (the macrotask queue), even if the timer was scheduled first. It rarely
matters day to day, but it's the answer when two async operations run in a different order than you
expected.
Callbacks, then Promises, then async/await
The earliest style for "run this when the slow thing finishes" was a plain callback function:
callback style (you'll rarely write this today)getTodos(function (todos) {
render(todos);
});
Nest a few of these and you get "callback hell" — a staircase of indentation that's hard to read and hard to handle errors in. A Promise fixes this by representing "a value that will exist eventually" as an object you can chain:
Promise chainfetch('/api/todos')
.then(response => response.json())
.then(todos => render(todos))
.catch(error => console.error('Failed to load todos', error));
A promise is always in one of three states: pending, fulfilled, or rejected. .then()
runs on fulfillment, .catch() runs on rejection. This is better than nested callbacks, but
long chains are still awkward to read compared to ordinary top-to-bottom code.
async/await is syntax sugar over promises that makes asynchronous code read like synchronous code:
async/await — the style you'll use throughout this courseasync function loadTodos() {
try {
const response = await fetch('/api/todos');
const todos = await response.json();
render(todos);
} catch (error) {
console.error('Failed to load todos', error);
}
}
The await keyword pauses execution of this function — and only this
function — until the promise settles. The rest of the program keeps running. A function marked
async always returns a promise itself, even if you write a plain return
inside it.
Callbacks, promises, and async/await are three syntaxes for the same underlying mechanism — the event loop and task queue from the diagram above. async/await is just the easiest one to read, so it's what you'll see in every server route and API call for the rest of this course.
Forgetting await doesn't throw an error — it just gives you the Promise object
itself instead of the value inside it. If you ever log something and see
Promise {<pending>} where you expected data, you forgot an await.
Open your browser's developer console right here on this page and run
fetch('https://drlongnecker.com').then(r => console.log(r.status)). Then try it again
with await in front, in a console that supports top-level await. Compare what each line
immediately returns versus what eventually logs.
This only works because you're viewing this page on drlongnecker.com itself —
a page's JavaScript can only fetch another origin if that server explicitly allows it
(CORS,
covered properly in Chapter 9). Try the same line from
a different site's console and it'll likely fail — a small, concrete preview of a rule you'll
see enforced deliberately, on purpose, once you're writing servers of your own.
Organizing code into modules #
Every example so far has lived in one imaginary file. Real programs don't: they're split across many
files, each responsible for one thing, that need to share code with each other. A
module
is just a file that says, explicitly, what it makes available to other files (export) and
what it needs from them (import). This is the
ES module
(ESM) syntax, and it's what every code sample from Chapter 4 onward assumes you already know.
export function addTodo(title) {
return { id: Date.now(), title, done: false };
}
export const MAX_TITLE_LENGTH = 200;
main.js — a module that imports them
import { addTodo, MAX_TITLE_LENGTH } from './todos.js';
const todo = addTodo("Buy milk");
console.log(todo, MAX_TITLE_LENGTH);
These are named exports: a file can have as many as it wants, and the importing file picks exactly which names it needs, in curly braces. A file can also have exactly one default export — typically the "main thing" that file is for — imported without curly braces and under whatever name you choose:
a default export// TodoStore.js
export default class TodoStore { /* ... */ }
// main.js
import TodoStore from './TodoStore.js'; // no braces, and the name is up to you
You'll see both patterns throughout this course: small, focused files with a handful of named exports (a set of route handlers, a set of utility functions), and files with one default export where the file is the thing (a single Vue component, a single class).
Two reasons. First, every dependency is explicit and traceable — if main.js
doesn't import something, it cannot use it, full stop, which makes it possible to reason
about one file without holding the entire program in your head. Second, nothing is global by default:
two files can each declare their own MAX_TITLE_LENGTH with completely different values
and never collide, because each only exists inside the module that declared it unless explicitly
exported.
Node.js, modern browsers, and every tool in this course (Vite, Vitest, TypeScript) all understand
this import/export syntax natively today. You may still encounter an older
syntax, require('./todos.js'), in tutorials, older codebases, or some Node.js internals
— it's from CommonJS, an earlier module system that predates ESM. The two are conceptually similar
but not directly interchangeable in the same file; this course uses import/export
throughout.
Why this matters for everything that follows #
Every framework, build tool, and server library in this course is written in JavaScript or
TypeScript (JavaScript plus types — Chapter 3). A component's reactive state is a variable. A
click handler is a function. Loading data when a page opens is an async function calling
fetch. There is no new execution model waiting for you later — just these same
pieces, arranged into increasingly useful structures.