TypeScript Concepts Make More Sense Inside React

A practical TypeScript and React guide to the event loop, hoisting, throttling, debouncing, timers, closures, callbacks, IIFEs, promises, async, and await through code patterns that show up in real components.

By Jovani Pink December 2, 2025 16 min — Systems & Complexity Notes

Outcome focus: Provided a React-centered runtime map and reusable TypeScript examples for debugging async UI behavior, timer cleanup, stale closures, callback flow, and promise-based rendering work.

JavaScript concepts get taught like vocabulary words.

Event loop. Hoisting. Closure. Callback. Promise.

That is fine for an interview flashcard. It is not enough when a React component flickers, a copied state never resets, a search box fires too many updates, or a table of contents highlights the wrong section.

In a React and TypeScript codebase, these concepts are not trivia. They explain why UI work behaves the way it does.

This site has several small examples that make the runtime visible:

  • CodeBlock copies text to the clipboard, flips a copied state, then resets it with setTimeout.
  • Mermaid dynamically imports a browser-only rendering library inside useEffect.
  • LinksPageClient filters cards in response to button callbacks.
  • PostToc uses IntersectionObserver, an event-driven browser API, to update the active heading.

Those components are not complicated. That is why they are useful. The same mechanics that power simple UI affordances also explain harder bugs in large applications.

The first version of many React bugs reads like this: "I changed the state, but the old value was used," or "I added a timeout, but it kept firing after the component left," or "I made the function async, and now the loading state is weird."

Those are runtime problems wearing component clothes.

The Runtime Map#

The browser does not run your React code in a magical React dimension. Your code still runs through JavaScript's execution model: call stack, tasks, microtasks, browser APIs, and rendering.

MDN's event loop guide is the best starting point for the mental model. The practical version for React work looks like this:

React components sit on top of the JavaScript runtime. Timers, promises, callbacks, and browser APIs all feed back into render work.

Keep that diagram nearby when debugging.

If a callback runs too often, look at events and dependencies. If state appears stale, look at closures. If an update happens after unmount, look at cleanup. If a promise resolves out of order, look at cancellation or request identity. If a timer survives longer than the UI, look at the effect that created it.

1. Event Loop#

The event loop is the mechanism that lets JavaScript do useful asynchronous work while running on a single main thread in the browser.

At a high level:

  • synchronous code runs on the call stack,
  • browser APIs handle delayed or external work,
  • completed work is queued,
  • microtasks such as promise continuations run before the next task,
  • the browser gets chances to render between turns.

React uses this environment. A click handler is a callback invoked by the browser. A setTimeout callback runs later as a task. A Promise continuation from await runs as a microtask. A component render happens after React schedules and processes updates.

Consider a simplified copy button based on this repo's CodeBlock component:

CodeBlockCopyButton.tsx
"use client";
 
import { useRef, useState } from "react";
 
const COPY_RESET_MS = 1600;
 
export function CodeBlockCopyButton() {
  const preRef = useRef<HTMLPreElement>(null);
  const [copied, setCopied] = useState(false);
 
  const handleCopy = async () => {
    const text = preRef.current?.innerText ?? "";
    if (!text) return;
 
    await navigator.clipboard.writeText(text);
    setCopied(true);
 
    setTimeout(() => {
      setCopied(false);
    }, COPY_RESET_MS);
  };
 
  return (
    <div>
      <pre ref={preRef}>const answer = 42;</pre>
      <button type="button" onClick={handleCopy}>
        {copied ? "Copied" : "Copy"}
      </button>
    </div>
  );
}

The runtime order matters:

  1. User clicks the button.
  2. Browser calls handleCopy.
  3. navigator.clipboard.writeText returns a Promise.
  4. await pauses the function and lets the current turn finish.
  5. When the promise resolves, the continuation runs.
  6. setCopied(true) schedules React work.
  7. setTimeout schedules another callback for later.
  8. After the delay, setCopied(false) schedules another React update.

If the button never resets, inspect the timer. If it resets too early, inspect the delay or repeated clicks. If the component unmounts before the timer fires, add cleanup.

2. Hoisting#

Hoisting is JavaScript's behavior of setting up declarations before code executes. MDN's glossary entry on hoisting is careful because different declarations behave differently.

The practical TypeScript rule is simple:

  • function declarations can be called before they appear in the file,
  • var declarations are hoisted with an initial value of undefined,
  • let and const are hoisted but unusable before initialization,
  • function expressions assigned to const behave like the const.

In React code, this affects how you organize helpers and components.

hoisting-example.tsx
export function TagLabel({ tag }: { tag: string }) {
  return <span>{formatTag(tag)}</span>;
}
 
function formatTag(tag: string) {
  return tag.trim().toLowerCase();
}

This works because formatTag is a function declaration.

This version does not:

hoisting-error.tsx
export function TagLabel({ tag }: { tag: string }) {
  return <span>{formatTag(tag)}</span>;
}
 
const formatTag = (tag: string) => tag.trim().toLowerCase();

The binding exists, but it cannot be read before initialization. TypeScript will usually catch this.

In component files, I prefer one of two patterns:

component-file-pattern.tsx
type TagLabelProps = {
  tag: string;
};
 
export function TagLabel({ tag }: TagLabelProps) {
  return <span>{formatTag(tag)}</span>;
}
 
function formatTag(tag: string) {
  return tag.trim().toLowerCase();
}

or:

component-file-expression-pattern.tsx
type TagLabelProps = {
  tag: string;
};
 
const formatTag = (tag: string) => tag.trim().toLowerCase();
 
export function TagLabel({ tag }: TagLabelProps) {
  return <span>{formatTag(tag)}</span>;
}

Pick one. Consistency matters more than arguing about declaration style forever.

The failure mode is accidental reordering. A developer moves helpers below the component, converts declarations to const function expressions, and suddenly code that relied on hoisting breaks.

3. Throttling and Debouncing#

Throttling and debouncing both control frequency. They solve different UI problems.

Debouncing waits until activity stops. Use it for search input, autosave, validation, and resize handling when only the final value matters.

Throttling runs at most once per interval. Use it for scroll, pointer movement, window resize, and telemetry where you want regular updates without flooding the app.

Here is a typed debounce hook for a React search box:

useDebouncedValue.ts
import { useEffect, useState } from "react";
 
export function useDebouncedValue<T>(value: T, delayMs: number) {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const id = window.setTimeout(() => {
      setDebounced(value);
    }, delayMs);
 
    return () => window.clearTimeout(id);
  }, [value, delayMs]);
 
  return debounced;
}

Used in a client component:

SearchFilter.tsx
"use client";
 
import { useMemo, useState } from "react";
import { useDebouncedValue } from "./useDebouncedValue";
 
type PostSummary = {
  title: string;
  tags: string[];
};
 
export function SearchFilter({ posts }: { posts: PostSummary[] }) {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 250);
 
  const visiblePosts = useMemo(() => {
    const normalized = debouncedQuery.trim().toLowerCase();
    if (!normalized) return posts;
 
    return posts.filter((post) => {
      const haystack = `${post.title} ${post.tags.join(" ")}`.toLowerCase();
      return haystack.includes(normalized);
    });
  }, [posts, debouncedQuery]);
 
  return (
    <>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        aria-label="Search posts"
      />
      <p>{visiblePosts.length} posts</p>
    </>
  );
}

The user can type quickly. The expensive filtering only reacts after the query settles.

Throttling has a different shape:

useThrottledCallback.ts
import { useCallback, useRef } from "react";
 
export function useThrottledCallback<TArgs extends unknown[]>(
  callback: (...args: TArgs) => void,
  intervalMs: number,
) {
  const lastRunRef = useRef(0);
 
  return useCallback(
    (...args: TArgs) => {
      const now = Date.now();
      if (now - lastRunRef.current < intervalMs) return;
 
      lastRunRef.current = now;
      callback(...args);
    },
    [callback, intervalMs],
  );
}

Use it when you want to react regularly, but not on every event:

ScrollDepthTracker.tsx
"use client";
 
import { useEffect, useState } from "react";
import { useThrottledCallback } from "./useThrottledCallback";
 
export function ScrollDepthTracker() {
  const [scrollY, setScrollY] = useState(0);
 
  const updateScrollY = useThrottledCallback(() => {
    setScrollY(window.scrollY);
  }, 250);
 
  useEffect(() => {
    window.addEventListener("scroll", updateScrollY, { passive: true });
    return () => window.removeEventListener("scroll", updateScrollY);
  }, [updateScrollY]);
 
  return <p>Scroll depth: {scrollY}px</p>;
}

The tradeoff is freshness versus cost. Debounce gives you fewer updates with more delay. Throttle gives you regular updates with less precision.

4. setTimeout and setInterval#

setTimeout runs once later. setInterval runs repeatedly. MDN documents both timer APIs, including an important practical point: the delay is not an exact guarantee. It is a minimum delay before the callback can be queued.

In React, timers need cleanup.

This is good for one delayed reset:

copied-state-reset.tsx
import { useEffect, useState } from "react";
 
const COPY_RESET_MS = 1600;
 
export function CopyStatus({ copied }: { copied: boolean }) {
  const [visible, setVisible] = useState(copied);
 
  useEffect(() => {
    if (!copied) {
      setVisible(false);
      return;
    }
 
    setVisible(true);
 
    const id = window.setTimeout(() => {
      setVisible(false);
    }, COPY_RESET_MS);
 
    return () => window.clearTimeout(id);
  }, [copied]);
 
  return visible ? <span>Copied</span> : null;
}

This is good for repeated work:

PollingStatus.tsx
import { useEffect, useState } from "react";
 
type Status = "idle" | "running" | "done";
 
async function fetchStatus(): Promise<Status> {
  const response = await fetch("/api/status");
  if (!response.ok) throw new Error("Failed to load status");
  return response.json() as Promise<Status>;
}
 
export function PollingStatus() {
  const [status, setStatus] = useState<Status>("idle");
 
  useEffect(() => {
    let cancelled = false;
 
    const poll = async () => {
      const nextStatus = await fetchStatus();
      if (!cancelled) setStatus(nextStatus);
    };
 
    poll();
    const id = window.setInterval(poll, 5000);
 
    return () => {
      cancelled = true;
      window.clearInterval(id);
    };
  }, []);
 
  return <p>Status: {status}</p>;
}

Use ReturnType<typeof setTimeout> when writing shared TypeScript that might run in Node and the browser:

timer-type.ts
let timer: ReturnType<typeof setTimeout> | null = null;
 
timer = setTimeout(() => {
  timer = null;
}, 500);

In browser-only React components, window.setTimeout returns a number, which can avoid Node timer type confusion.

5. Closures#

A closure is what lets a function remember variables from the scope where it was created. MDN's closure guide is worth reading because closures are everywhere in React.

Every event handler closes over props, state, and local variables.

closure-counter.tsx
import { useState } from "react";
 
export function Counter() {
  const [count, setCount] = useState(0);
 
  const incrementLater = () => {
    setTimeout(() => {
      setCount(count + 1);
    }, 1000);
  };
 
  return (
    <button type="button" onClick={incrementLater}>
      Count: {count}
    </button>
  );
}

This looks fine until the user clicks three times quickly. Each timeout closes over the count value from the render that created it. If that value was 0, all three callbacks may try to set the count to 1.

Use a functional state update when the next value depends on the previous value:

closure-counter-fixed.tsx
import { useState } from "react";
 
export function Counter() {
  const [count, setCount] = useState(0);
 
  const incrementLater = () => {
    setTimeout(() => {
      setCount((current) => current + 1);
    }, 1000);
  };
 
  return (
    <button type="button" onClick={incrementLater}>
      Count: {count}
    </button>
  );
}

Closures are not a bug. Stale closures are the bug.

When debugging, ask: which render created this function, and what values did that function close over?

6. Callback Functions#

A callback is a function passed to another function so it can be called later.

React is full of callbacks:

  • onClick,
  • onChange,
  • useEffect setup functions,
  • setTimeout handlers,
  • array methods such as map and filter,
  • observer APIs such as IntersectionObserver,
  • promise handlers such as .then.

The LinksPageClient pattern is a simple callback example:

TagFilter.tsx
"use client";
 
import { useState } from "react";
 
type TagFilterProps = {
  tags: string[];
};
 
export function TagFilter({ tags }: TagFilterProps) {
  const [selectedTag, setSelectedTag] = useState<string | null>(null);
 
  return (
    <nav aria-label="Filter by tag">
      <button type="button" onClick={() => setSelectedTag(null)}>
        All
      </button>
 
      {tags.map((tag) => {
        const isActive = tag === selectedTag;
 
        return (
          <button
            key={tag}
            type="button"
            aria-pressed={isActive}
            onClick={() => setSelectedTag(isActive ? null : tag)}
          >
            {tag}
          </button>
        );
      })}
    </nav>
  );
}

The onClick prop receives a callback. React calls it later when the user clicks.

Callbacks also power browser APIs:

toc-observer.tsx
import { useEffect, useState } from "react";
 
export function ActiveHeading({ ids }: { ids: string[] }) {
  const [activeId, setActiveId] = useState<string | null>(ids[0] ?? null);
 
  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      const visible = entries
        .filter((entry) => entry.isIntersecting)
        .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
 
      if (visible[0]) setActiveId(visible[0].target.id);
    });
 
    ids.forEach((id) => {
      const element = document.getElementById(id);
      if (element) observer.observe(element);
    });
 
    return () => observer.disconnect();
  }, [ids]);
 
  return <p>Active: {activeId}</p>;
}

The callback is not called by your component. It is called by the browser when intersection entries are available.

That is the mental shift: callbacks are a way to hand future behavior to another owner.

7. IIFE#

An IIFE is an immediately invoked function expression. It creates a function and calls it right away.

Classic JavaScript used IIFEs to create private scope before modules were standard:

classic-iife.ts
const analytics = (() => {
  let pageViews = 0;
 
  return {
    trackPageView() {
      pageViews += 1;
      return pageViews;
    },
  };
})();

Modern TypeScript modules already give you file scope, so you do not need IIFEs for privacy as often.

In React code, the IIFE still appears in two useful places.

First, computing a value inline when JSX would otherwise get noisy:

jsx-iife.tsx
type Status = "draft" | "published" | "archived";
 
export function StatusBadge({ status }: { status: Status }) {
  return (
    <span>
      {(() => {
        if (status === "published") return "Live";
        if (status === "archived") return "Archived";
        return "Draft";
      })()}
    </span>
  );
}

Use that sparingly. A helper function is usually cleaner.

Second, wrapping async work inside useEffect, because the effect callback itself should return either nothing or a cleanup function, not a promise:

async-iife-in-effect.tsx
import { useEffect, useState } from "react";
 
export function RemoteTitle({ slug }: { slug: string }) {
  const [title, setTitle] = useState<string | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    void (async () => {
      const response = await fetch(`/api/posts/${slug}`);
      const data = (await response.json()) as { title: string };
 
      if (!cancelled) {
        setTitle(data.title);
      }
    })();
 
    return () => {
      cancelled = true;
    };
  }, [slug]);
 
  return <h1>{title ?? "Loading..."}</h1>;
}

The void signals that the promise is intentionally not awaited by the effect body. The cleanup still works synchronously.

8. Async, Await, and Promises#

A Promise represents a value that may be available now, later, or never because it failed. MDN's Promise reference covers the states and chaining model. async and await are syntax that make promise code read more like sequential code.

React and Next.js use promises heavily. In this codebase, route components use async server functions, and the Mermaid component dynamically imports the rendering library on the client.

Here is a simplified client-side rendering pattern:

DynamicRenderer.tsx
"use client";
 
import { useEffect, useState } from "react";
 
type RenderState =
  | { status: "idle" }
  | { status: "ready"; html: string }
  | { status: "error"; message: string };
 
export function DynamicRenderer({ source }: { source: string }) {
  const [state, setState] = useState<RenderState>({ status: "idle" });
 
  useEffect(() => {
    let cancelled = false;
 
    const render = async () => {
      try {
        const module = await import("./expensive-renderer");
        const html = await module.renderToHtml(source);
 
        if (!cancelled) {
          setState({ status: "ready", html });
        }
      } catch (error) {
        if (!cancelled) {
          setState({
            status: "error",
            message: error instanceof Error ? error.message : "Render failed",
          });
        }
      }
    };
 
    render();
 
    return () => {
      cancelled = true;
    };
  }, [source]);
 
  if (state.status === "error") return <p>{state.message}</p>;
  if (state.status === "idle") return <p>Loading...</p>;
 
  return <div dangerouslySetInnerHTML={{ __html: state.html }} />;
}

There are four important details:

  • the async function lives inside the effect,
  • errors are handled with try and catch,
  • a cancellation flag prevents state updates after cleanup,
  • state is represented as a discriminated union instead of loose booleans.

The discriminated union is the TypeScript part I care about:

render-state.ts
type RenderState =
  | { status: "idle" }
  | { status: "ready"; html: string }
  | { status: "error"; message: string };

Now the component cannot accidentally read state.html while the status is "error". TypeScript narrows the type based on the status field.

For server components or route handlers, the async shape is simpler:

server-page.tsx
type Post = {
  slug: string;
  title: string;
};
 
async function getPost(slug: string): Promise<Post | null> {
  const response = await fetch(`https://example.com/posts/${slug}`);
  if (response.status === 404) return null;
  if (!response.ok) throw new Error("Failed to fetch post");
 
  return response.json() as Promise<Post>;
}
 
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
 
  if (!post) return <p>Post not found.</p>;
 
  return <h1>{post.title}</h1>;
}

The difference is ownership. On the server, the component can be async. In a client useEffect, the effect callback should not be async because React expects the return value to be a cleanup function or nothing. React's useEffect reference is the source to use for that lifecycle contract.

How These Concepts Work Together#

A realistic component uses several of these ideas at once.

Imagine a client-side post search:

PostSearch.tsx
"use client";
 
import { useEffect, useMemo, useState } from "react";
 
type PostSummary = {
  slug: string;
  title: string;
  summary: string;
};
 
function useDebouncedValue<T>(value: T, delayMs: number) {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const id = window.setTimeout(() => setDebounced(value), delayMs);
    return () => window.clearTimeout(id);
  }, [value, delayMs]);
 
  return debounced;
}
 
export function PostSearch({ posts }: { posts: PostSummary[] }) {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 250);
 
  const visiblePosts = useMemo(() => {
    const normalized = debouncedQuery.trim().toLowerCase();
    if (!normalized) return posts;
 
    return posts.filter((post) => {
      return `${post.title} ${post.summary}`.toLowerCase().includes(normalized);
    });
  }, [posts, debouncedQuery]);
 
  return (
    <section>
      <label htmlFor="post-search">Search posts</label>
      <input
        id="post-search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
 
      <ul>
        {visiblePosts.map((post) => (
          <li key={post.slug}>{post.title}</li>
        ))}
      </ul>
    </section>
  );
}

This small example includes:

  • callback functions through onChange, filter, and map,
  • closures over query, posts, and debouncedQuery,
  • setTimeout and cleanup inside the debounce hook,
  • event loop behavior because typing, timers, and rendering happen on different turns,
  • hoisting choices around helper placement,
  • TypeScript generics in useDebouncedValue<T>.

You do not need to recite definitions to debug it. You need to know which concept owns the symptom.

Debugging Checklist#

When the UI behaves strangely, map the bug to the concept:

runtime-debugging-checklist.md
# React and TypeScript Runtime Checklist
 
## Event loop
- Did a promise, timer, observer, or event callback run later than expected?
- Did a microtask update state before a timer callback?
 
## Hoisting
- Was a helper converted from a function declaration to a const expression?
- Is code reading a binding before initialization?
 
## Throttling and debouncing
- Should this update wait until input stops?
- Should this update run at most once per interval?
- Is cleanup canceling stale timers?
 
## Timers
- Is `setTimeout` or `setInterval` cleared on cleanup?
- Can the callback run after the component unmounts?
 
## Closures
- Which render created this function?
- Is the callback reading stale state?
- Should the state update use a functional updater?
 
## Callbacks
- Who calls this function: React, the browser, a timer, an observer, or a promise?
- Does the callback need stable identity?
 
## IIFE
- Is an immediate function making JSX harder to read?
- Is an async IIFE inside an effect hiding errors?
 
## Async and promises
- Are errors handled?
- Can responses resolve out of order?
- Is state modeled clearly for loading, success, and failure?

What to Practice#

Do not learn these concepts as separate trivia items.

Practice them in one small React component:

  1. Add an input.
  2. Debounce the input.
  3. Filter a list with useMemo.
  4. Add a copy button with navigator.clipboard.
  5. Reset the copied state with setTimeout.
  6. Add a fake async fetch with loading and error states.
  7. Add cleanup so unmounted components do not update state.
  8. Write one test for the visible behavior.

The concepts will stop feeling abstract because the runtime will start leaving fingerprints in the UI.

That is the useful version of TypeScript and JavaScript knowledge: not being able to define a closure, but knowing when a closure is the reason your React component lied to you.

Back to all writing
On this page
  1. The Runtime Map
  2. 1. Event Loop
  3. 2. Hoisting
  4. 3. Throttling and Debouncing
  5. 4. setTimeout and setInterval
  6. 5. Closures
  7. 6. Callback Functions
  8. 7. IIFE
  9. 8. Async, Await, and Promises
  10. How These Concepts Work Together
  11. Debugging Checklist
  12. What to Practice