Frontend Dev – The JavaScript and TypeScript You Actually Need

August 7, 20267 min readUpdated 8/20/2026

JavaScript is the only language a browser runs, so you are going to learn it whether you like it or not. But the subset that matters in a component app is not the subset a beginner course teaches. Nobody spends their day on for loops and string methods. They spend it on immutable updates, async, modules, and describing the shape of data that arrived from somewhere else.

This post names that subset. The language itself is 19 posts over here; this is what to prioritise and why.

The parts that carry a component app

Updating without mutating

This is the single biggest habit change coming from most other languages. Frameworks decide what to redraw by asking "is this a different object than last time?". Change an object in place and the answer is no, so nothing re-renders and you get the classic bug where the data is right and the screen is wrong.

// Mutates: same array reference, so React sees no change and does not re-render.
items.push(newItem);

// Replaces: a new array, so the change is visible.
const next = [...items, newItem];

The three you will write constantly — add, replace one, remove one:

const added   = [...items, newItem];
const updated = items.map((i) => (i.id === target.id ? { ...i, quantity: 2 } : i));
const removed = items.filter((i) => i.id !== target.id);

The demo app's Redux catalogue has exactly this helper, used for products, toppings and crusts:

/** Insert if new, replace if already present — the same shape for all three collections. */
function upsert<T extends { id: UUID }>(list: T[], item: T): T[] {
  return list.some((existing) => existing.id === item.id)
    ? list.map((existing) => (existing.id === item.id ? item : existing))
    : [...list, item];
}

Note the trap: the spread is shallow. { ...order } gives you a new order whose items array is still the original one. Nested updates need spreading at every level you change — which is one honest argument for Redux Toolkit, whose reducers let you write mutating-looking code and produce immutable results underneath.

The async model, which is what makes a spinner possible

JavaScript runs your code on one thread. If that thread is busy, nothing renders and nothing responds to clicks. Everything slow — network, timers, file reads — therefore returns a promise: a placeholder for a value that is not here yet.

const response = await api.post<AuthenticationResponse>(path, body);

Three things to actually understand, rather than memorise:

  • await does not block the browser. It suspends this function and lets everything else carry on. That is why the page still scrolls while a request is in flight.
  • An async function always returns a promise, even when you return a plain value. Forgetting to await a call gives you a promise where you wanted a number, and it will not error — it will just render [object Promise].
  • A rejected promise with no catch is an unhandled rejection. In a UI that is a button that spins forever.

The shape you will write hundreds of times is try/catch/finally, because finally is what guarantees the spinner stops whichever way it went:

setLoading(true);
setError(null);
try {
  const response = await api.post<AuthenticationResponse>(path, body);
  tokenStore.set(response.token);
  setUser(response.user);
} catch (err) {
  const message =
    err instanceof ApiError ? err.message : 'Could not reach the server. Is the API running?';
  setError(message);
  throw err;
} finally {
  setLoading(false);
}

Also worth knowing: Promise.all to run independent requests concurrently instead of one after another, and AbortController to cancel one whose answer you no longer want — post 7. Promises in depth.

Modules

Every file is a module with its own scope. Only what you export is visible, and only what you import is loaded. That last part is not just tidiness — it is what lets the bundler drop unused code and split your app into chunks (post 12).

export type RootState = ReturnType<typeof store.getState>;

Prefer named exports. They are greppable and they survive a rename; a default export can be imported under any name at all, so the same component ends up called three different things across a codebase.

Destructuring, defaults, and the two operators worth learning

const { method = 'GET', body, auth = false, signal } = options;

That single line pulls four fields out and supplies two defaults. You will read it in every codebase you ever open.

The two operators people confuse:

  • ?? — nullish coalescing. Falls through only on null or undefined.
  • || — falls through on any falsy value, which includes 0 and ''.

For anything numeric that is a real bug: price || 10 turns a legitimately free item into ten dollars, while price ?? 10 does not. And ?. optional chaining short-circuits instead of throwing on a missing object — which matters constantly with API data that has optional fields.

TypeScript, and why it is worth it

TypeScript is JavaScript plus type annotations, checked at build time and then erased. The browser never sees a type. The point is that the class of bug it catches is exactly the class of bug frontend code is most prone to: the data was not the shape I assumed.

Type the boundary first

If you do nothing else, type the API responses. That is where your assumptions and reality diverge. The demo app keeps one file for it, and says why:

/**
 * Shared domain types.
 *
 * These mirror the Spring Boot API's DTOs exactly. Keeping one file as the contract means that if
 * the shapes drift, TypeScript fails the build instead of the UI failing at runtime.
 */

"Fails the build instead of failing at runtime" is the entire value proposition in one sentence.

Unions beat strings

export type OrderStatus =
  | 'PENDING_PAYMENT'
  | 'PAID'
  | 'PREPARING'
  | 'COMPLETED'
  | 'CANCELLED';

Now a typo is a compile error, autocomplete offers the five real values, and if you add a sixth the compiler can point at every switch that no longer covers them all. As string, all of that is on you.

The same idea names intent even when the underlying type is just text:

export type UUID = string;

Model loading state as a union

A very common beginner shape is three independent fields — data, loading, error — which permits nonsense combinations like loading and errored at once, and forces every render to defend against them. A discriminated union makes the impossible states unrepresentable:

type Remote<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'ready'; data: T };

Switch on status and TypeScript narrows the type inside each branch, so data only exists where it actually exists.

Errors can carry structure

A thrown Error with a string message loses everything the API told you. The demo app subclasses it so a form can render field-level messages:

/** An error carrying the API's structured body, so callers can show field-level messages. */
export class ApiError extends Error {
  readonly status: number;
  readonly body: ApiErrorBody | null;

  /** Field errors as a lookup, for rendering next to inputs. */
  fieldErrors(): Record<string, string> {
    const result: Record<string, string> = {};
    for (const sub of this.body?.errors ?? []) {
      if (sub.field) result[sub.field] = sub.message;
    }
    return result;
  }
}

Note this.body?.errors ?? [] — optional chaining and nullish coalescing doing real work on a field the API may omit.

Derive types, do not declare them twice

Any type you write out by hand next to a value can drift from it. Derive it instead and it cannot:

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Add a Redux slice and RootState grows on its own.

`any` is a loss, `unknown` is a question

any switches the checker off for that value and everything downstream of it — one any at a boundary quietly untypes half a feature. unknown says "I do not know yet", and forces a check before use. The demo app parses responses as unknown before deciding what they are, which is the honest description of a JSON body that has just arrived over a network.

What to skip for now

SkipWhy
Deep this and bind rulesArrow functions and hooks removed most of the need. Still worth reading once for interviews.
Prototypes and class hierarchiesModern frontend is functions and plain objects. You need class to subclass Error and little else.
Advanced conditional/mapped typesLibrary-author territory. You will read them long before you need to write them.
Decorators, generators, proxiesReal, rare, and not on the path to a first job.
jQueryOnly if you inherit it.

The one thing to take from this post

Learn immutable updates until they are reflex, and type the boundary where data enters your app. Those two habits prevent most of the bugs you would otherwise spend your first year debugging — the screen that will not update, and the crash on a field that was null this time.

Next: What to Learn in a Framework.