Frontend: React, TypeScript, Vite and TanStack Query

How a modern single-page app is built: React 19 components, hooks and rendering, what strict TypeScript catches, how Vite and Rolldown bundle and split the code, why server data lives in TanStack Query and not Redux, a layered structure a script enforces, bundle budgets, service workers, accessibility with ARIA and axe, theming with design tokens and the SignalR client - step by step, with pictures.

In short

The {{brand}} web app is a single-page application: the browser loads one HTML page once, and JavaScript draws every screen after that. It is written in React 19 and TypeScript and bundled by Vite, whose production bundler is Rolldown. Nearly all of its data is a copy of what the server said, kept fresh by TanStack Query. A script enforces a layered structure, every bundle has a size budget, and a separate accessibility sweep runs on the built app before releases.

After this lesson you can Explain how React renders and when effects run. Tell server state from client state and use query keys, invalidation and optimistic updates. Split a bundle and hold it to a budget. Make a page accessible, themeable, installable and live with SignalR.

Words you need

The whole stack, and the first page load

A frontend is many tools working together, each with one job. This is the stack of {{brand}}'s web app and what each part does.

React 19: components, hooks and rendering

React builds user interfaces from components. You describe what the screen should look like for the current data, and React works out how to change the real page. You never move page elements by hand: you change state, and React renders again.

StrictMode runs effects twice - on purpose In development, StrictMode mounts each component, unmounts it and mounts it again, to prove every effect cleans up after itself. Code that opens a connection must close it in the cleanup. {{brand}}'s live connections start one tick later, so a mount that is undone at once never begins a handshake it has to abort.

React patterns worth knowing

React decides what to redraw by comparing references: if an object is the same one as before, nothing changed. So a change must make a new object and leave the old one untouched - an immutable update. By hand, a change deep in a tree means copying every level on the way down, which is long and easy to get wrong. Immer does that work for you: you edit a draft, and it returns the new object.

produceWithPatches also returns patches - small records such as "replace nodes/0/x with 240" - and the inverse patches that undo them. Undo is applyPatches(state, inverse); redo is applyPatches(state, patches). A stack of patches holds only what changed, so it is far smaller than a stack of full copies of a large document. {{brand}}'s design editor works this way: every change, however big, is one function applied to a draft, and the undo history is a list of patches. Patches must be switched on once with enablePatches(), and Map and Set support with enableMapSet().

The alternatives are copying by hand with the spread operator (no dependency, fine for one or two levels, noisy and error-prone deeper), Immutable.js (persistent data structures with fast updates, but its own types and get/set API, so you convert at every border with the rest of your code) and newer drop-in libraries such as Mutative, which advertise faster updates but have a smaller community. Immer fits plain objects and arrays: no new data types, the TypeScript types stay the same, and Redux Toolkit uses it inside its reducers too. The costs are a small proxy overhead and the traps below.

Three Immer traps Do not both change the draft and return a new value from the recipe - pick one. Do not keep a draft after produce has finished; it is revoked. Instances of your own classes are copied only if you mark them with Immer's immerable symbol, and a frozen result cannot be changed later - copy it first if you must.

React 19 also brought Actions (useActionState, useOptimistic, useFormStatus) and the use() hook. They shine when a form posts straight to the server. {{brand}}'s writes go through TanStack Query mutations instead, which give the same pending, error and optimistic states plus one shared cache.

Report the crashes you cannot see An error boundary catches a crash in rendering and shows a fallback, but nobody learns about it. Send a small report from the boundary's componentDidCatch and from the window events error and unhandledrejection, which catch crashes outside React and promises nobody awaited.

TypeScript: mistakes caught before anyone sees them

TypeScript adds types to JavaScript: you say what a value is, and the compiler checks every use of it. A renamed field, a forgotten case, a value that may be empty - each becomes a red line in the editor instead of a bug report. The types disappear in the build; the browser runs plain JavaScript.

The bundler does not check types Vite and Rolldown strip types to stay fast; they never check them. That is why {{brand}}'s production build runs the TypeScript compiler first (tsc -b, then vite build). Skip that step and a type error ships.

Vite and Rolldown: from source files to a fast download

Vite has two halves. In development it serves your code to the browser and swaps a changed module in place - hot module replacement - so you see an edit without losing what is on screen. For production it hands the app to Rolldown, a bundler written in Rust with a Rollup-compatible API, which writes the files you deploy.

{{brand}} runs Vite's bundled development mode, still marked experimental. A refresh used to fetch hundreds of separate files one after another and took about four seconds before the header drew; with a few bundles it takes about one second. The vendor groups are ordered by priority on purpose: a group takes its libraries' own dependencies with it, so the small groups every page needs claim what they share with the heavy ones.

Cache forever, revalidate one file Because file names change with their content, the web server tells browsers to keep every hashed file for a long time. index.html, the service worker file, the manifest and the offline page are always revalidated, so the next page load after a deploy picks up the new build.

Build-time or runtime configuration Values that start with VITE_ are written into the bundle when it is built, not read when the page runs. The API address is therefore a build argument, and a build made without it silently points at localhost. Anything that must change per environment without a rebuild has to be fetched at run time instead.

Single-page apps and routing

In a multi-page site every click asks the server for a new HTML page. In a single-page app the page stays: the router changes the address with the History API and swaps the screen. It feels instant and keeps state alive across screens - but the app now owns the back button, deep links and loading.

The options in the market

What {{brand}} chose, and why

{{brand}} is a large app people use all day behind a sign-in: boards, test runs, notes, hiring. It needs fast screens, many people changing the same data, and a codebase where dozens of features grow without tangling. The choices follow from that.

The trade-off accepted A single-page app's first visit downloads more than a server-rendered page, and nothing works without JavaScript. {{brand}} pays that back with code splitting, a budget per chunk, preloading on intent, and a calm outline while the session is checked.

When another choice would be right: pick server rendering (Next.js, Remix) for public, content-heavy pages that must rank in search and paint fast on slow phones. Pick Angular when a large team wants one framework with every decision made. Pick Blazor when the team writes only C# and the first download matters less.

Server state vs client state

The most useful question in frontend design is who owns this data? If the server owns it, the browser holds only a copy that can go stale at any moment - someone else moves the card. Treat that copy as a cache, not as state you own, and most state bugs disappear.

{{brand}}'s defaults: data stays fresh for one minute, unused data is kept for five minutes, a failed read is retried once, and nothing refetches just because the window regained focus - a person typing in a form should not see the data under them jump.

TanStack Query in practice: keys, invalidation and mutations

Three ideas carry the whole library: a query reads, a mutation writes, and a key names the data. Get the keys right and invalidation is one line.

Design keys as a tree Invalidation matches by prefix. Put a board's card lists under the board's key, and every write that refreshes the board refreshes its lists too, without each mutation naming them. Put data that never changes with a board, such as the template catalogue, outside that prefix, so board writes never refetch it.

No write may fail silently {{brand}} gives the query client a mutation cache with one global error handler: any failed write shows a message with the next step that exists, such as Retry. A mutation that shows its error beside its own control says so in its meta, so the person never sees the same error twice.

Optimistic updates: show it before the server answers

Dragging a card should feel instant, not wait for the network. An optimistic update changes the cache first and sends the request behind it. The hard part is to undo it cleanly when the server says no.

Two quick clicks, one order Toggle a pin twice quickly and the two requests can arrive out of order. A mutation scope queues the writes to one resource so they run in sequence, and an Undo is offered only where a real way back exists.

Where Redux still fits

Redux keeps one global store that changes only through actions. Before query libraries existed, teams kept server data there too, and wrote loading flags, refresh logic and stale checks by hand for every entity. In {{brand}} the store holds no state at all: it is an event bus for the few moments the whole app must hear.

A rule of thumb If the data came from the server, it belongs in the query cache. If one screen uses it, keep it in component state. If it must survive a refresh, put it in the URL or in browser storage. Reach for a global store only for what is truly global and has no server home.

A layered frontend, enforced by a script

A big frontend rots when anything may import anything: a change in one screen breaks another, and a small page drags in the whole app. A common layout is four layers (app shell, screens, features, shared parts), with imports pointing only down.

A pitfall: the stylesheet that vanished in production Marking files as free of side effects lets a bundler skip a module that only re-exports. If that module also imported a CSS file, the CSS is skipped with it: unit tests pass and only the built app looks broken. Add a build check for it, or list such files as having side effects.

Code splitting, preloading and bundle budgets

Every byte in the first download delays the first screen, most of all on a phone. Code splitting makes each screen its own chunk, loaded on demand. Preloading fetches the next one before the click. A budget stops the first download from quietly growing back.

Each line is the measured size plus a small margin. A change that crosses a line fails the check: make something lazy, or raise the line and write down why. Budgets catch real mistakes, such as one screen quietly pulling a whole module into the first download. Typical results of budget-driven work are first visits that shrink by half.

A deploy deletes the chunks an open tab needs Hashed chunk names change every release. A tab left open for days still points at yesterday's files, so the first lazy screen it opens fails to load. Vite fires a preload error event; {{brand}} then reloads the page onto the new build - at most once in a short window, so a chunk that is truly missing shows an error instead of reloading forever.

Text per language Translations are best split into one chunk per language and per area, so a visitor downloads only their own language's text, and a screen's text arrives together with its code. That is why a budget line includes the text the first screen waits for.

Service workers and the installable app

A progressive web app is a website that installs like an app: its own window, icon and splash screen. Two files make that work: a web app manifest (name, icons, colours, start address) and a service worker - a script the browser runs between the page and the network.

Why not cache more? Caching the app itself makes it work offline, but a person can then run an old build after a release, and a shared computer keeps pages full of the last person's data. {{brand}} chose freshness and privacy over offline work.

Accessibility: semantic HTML, ARIA and axe

Accessibility means everyone can use the app - with only a keyboard, a screen reader, zoom or low vision. The cheapest way there is semantic HTML: a real button, a real label, a real dialog. ARIA attributes fill the gaps HTML cannot express; they change what assistive technology hears, never how the page behaves.

Automated checks are a floor axe finds missing names, poor contrast and broken ARIA. It cannot tell whether the focus order makes sense or an announcement helps - that is what the keyboard and dialog walks are for.

Theming with design tokens

A design token is a named design decision - surface, ink, focus - stored as a CSS custom property. Components use only the names; each theme gives the names other values. Switching to Dark is then one attribute on the html element, and no component changes.

Why a separate file, not an inline script If your site uses a strict Content-Security-Policy (a list of what the page may load and run), inline scripts may be forbidden. Keeping the theme script in its own file lets such a policy still work.

A contrast gate reads the token file, resolves every theme and accent the way a browser would - color-mix included - and checks each text and mark pair against the targets above. A theme added later is checked without anyone editing the gate.

The SignalR client: live updates in a React app

When someone else moves a card, your open board should show it. The SignalR client keeps a WebSocket open to the server's hub. The server sends a short signal - which board, which cards changed - and the client asks TanStack Query to refetch exactly those. The data still arrives through the normal queries, so there is one source of truth.

WebSockets and headers A browser WebSocket cannot send custom request headers, but cookies travel with it (withCredentials). Anything else must go in the address or the first message and be treated as untrusted input. A page with a Content-Security-Policy must also allow the hub's wss: address in connect-src.

The checks that run on every change

One command runs every check before code can merge. Each one turns a rule people would forget into a failing build.

Testing components without a browser Most of {{brand}}'s component tests render to static markup in Node and assert on the HTML - there is no simulated DOM to install and keep in step. What only a real browser shows, such as a dropped stylesheet, is checked by walks against the built app.

Try it yourself: a small app with every idea in it

In one evening you can build a tiny app that uses TanStack Query, code splitting and a bundle budget, and make it accessible. Every tool here is free.

Common mistakes

Interview questions

Related