A Small App, No Framework

Expert · 20 min read · ▶ live playground · ✦ checkpoint

You can build a useful browser app with plain TypeScript: typed state, DOM queries, event listeners, and one render() function. Frameworks are not magic; they organize this same loop when the app grows beyond what you want to manage by hand.

This lesson ties the browser section together. You will keep data in a typed array, validate form input before it becomes state, and re-render the page after every state change.

Vite is the normal starting point

In a real project, you rarely wire the browser TypeScript compiler yourself. Vite's TypeScript template gives you the everyday shape: an HTML entry point, a src/main.ts file, a dev server, and a production build that erases TypeScript before shipping JavaScript.

The setup is tool-shaped, not a new TypeScript concept:

npm create vite@latest focus-plan -- --template vanilla-ts
cd focus-plan
npm install
npm run dev

A small app then keeps scripts visible in package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  }
}

That build split matters. Vite can turn TypeScript syntax into JavaScript quickly, while tsc is the project-wide type check. The live playground below is smaller than a Vite project, but the code style is the same: query known elements, guard them, attach listeners, and let TypeScript check the DOM code before it runs.

State first, DOM second

The simplest no-framework architecture is a loop:

  1. Keep the source of truth in typed state.
  2. Render the DOM from that state.
  3. Let events validate inputs and create new state.
  4. Call render() again.

The DOM is not the source of truth. It is the view. If you scatter state across text nodes, checkboxes, hidden inputs, and local variables, every feature becomes a synchronization problem. A typed store keeps the app honest because every operation has to produce a valid PlanItem[].

Run the playground, add a plan item, mark items done, and delete one. Then open the TypeScript tab and look for the pattern: items changes, then render() redraws the list.

typescript + dom — live previewsandboxed
Run rebuilds the page

The state type is small on purpose:

  • Effort is a literal union, so only 1, 2, or 3 can enter state.
  • PlanItem is the one shape the renderer understands.
  • DraftResult is a discriminated union, so invalid form input must be handled before a new item is created.

Notice what does not happen in the add-button listener. It does not build list markup directly. It reads and validates the form, updates items, clears the form, and calls render(). That separation is the whole pattern.

The renderer owns the repetitive DOM work. It clears the list, calculates the summary, creates rows, wires checkbox and delete listeners, and appends the finished nodes. Those listeners do not keep their own private copies of the item. They create a new items array from the old one, then call render() again. That gives you a tiny version of the update loop most UI libraries formalize: data changes first, then the screen catches up from data.

Form input is a runtime boundary

Even though effortSelect is an HTMLSelectElement, its .value is still a string. Browser form controls do not know your literal union. The parseEffort function is the small validation boundary that turns "1" | "2" | "3"-shaped runtime text into the Effort type your app state accepts.

The title is validated too. Empty strings and two-letter tasks are real browser input, but they are not useful plan items, so readDraft() returns { ok: false, message } instead of pretending every button press is valid. Once draft.ok is true, TypeScript narrows the result and the state update can only use a valid title and effort.

That is the runtime-boundary rule from earlier sections, now applied to a form instead of a network response: strings come in, checked values enter the typed core.

Why frameworks exist

This pattern scales surprisingly far for small tools. It is also where you start feeling the reasons frameworks exist.

Manual rendering means you decide exactly which DOM nodes to create, update, remove, and wire with listeners. That is powerful, but repetitive. As state grows, you need conventions for derived values, nested components, validation messages, keyboard focus, preserving input state, and avoiding unnecessary redraws. A framework gives you a structured way to describe the view from state and lets its runtime handle much of the DOM bookkeeping.

React, which starts in the next section, does not remove the ideas you used here. You still model state, validate inputs, handle events, and render UI from data. It gives you a component model and a rendering engine so those ideas stay manageable when one small planner becomes a real product surface.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion