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
- Single-page application (SPA) One HTML page for the whole app. JavaScript draws each screen and asks the server only for data as you move.
- Component A function that takes props (its inputs) and returns what that part of the screen should show. A screen is a tree of components.
- Hook A function whose name starts with use: it gives a component memory (useState), side work (useEffect) or shared logic. Hooks run in the same order on every render.
- Render and commit Render is React calling your components to work out the new screen; commit is React changing only the parts of the real page (the DOM, the browser's tree of page elements) that differ.
- TypeScript JavaScript with types. The compiler checks that every value is used as what it is before the code runs; the build then removes the types.
- Bundler and code splitting A bundler follows every import from an entry file and writes a few optimised chunks with hashed file names. Code splitting cuts the app into chunks that load only when a screen needs them. Vite uses Rolldown. Size budgets count the gzip size - the compressed file the server really sends.
- Server state vs client state Server state is data the server owns and the browser only copies: cards, test runs, people. It can go stale while you look at it. Client state exists only in the browser: an open menu, a draft, the selected tab.
- Query key and invalidation The name a cached piece of server data is filed under, such as ['boards', id]. Same key, same data, one request. Invalidation marks data under a key as stale so it is fetched again - the usual answer after a write.
- WebSocket One long-lived connection where the server can push messages to the page without being asked.
- Optimistic update Showing the result of a write before the server confirms it, and undoing it if the server says no.
- Service worker A script the browser runs beside the page, even when it is closed. It can answer page requests and show notifications, and it makes an app installable.
- Immutable update (Immer) Changing data by building a new copy instead of editing the old one, which is how React notices a change. Immer lets you write ordinary edits on a temporary draft and builds the new copy for you.
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.
- The server sends index.html A small page with an empty root element, a tiny theme script and one module script. The server answers every app address with this same page, or with a copy in the visitor's language.
- A tiny theme script runs Before any CSS paints, it reads the remembered theme and sets it on the page, so a dark-theme user never sees a white flash.
- The entry script chooses It settles the page's language and loads the right entry as its own chunk.
- Providers wrap the app The router, the query client and the session provider are created once and stay mounted across every navigation.
- The session is checked One request asks the server who is signed in. Until it answers, a calm outline of the page shows.
- The router picks a screen The address decides the screen. Its code is a lazy chunk, downloaded now unless the link already fetched it.
- Hooks ask for data The screen's query hooks look in the cache, fetch what is missing, and React draws the result.
- A live connection opens Screens that change while you look at them join a SignalR group and refetch when the server says something changed.
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.
- Something happens A click, a keystroke, a server answer. A handler calls a state setter, such as setCount(count + 1).
- React schedules a render Several setters called in the same event are batched into one render. Nothing is drawn yet.
- Render React calls the components whose state or props changed. They return a description of the screen. Rendering must be pure: no requests, no timers, no writes.
- Reconcile React compares the new description with the last one, element by element, and uses keys to match items in a list.
- Commit Only the differences are written to the real page (the DOM). This is the only step that touches it.
- Effects run After the browser paints, useEffect callbacks run: subscribe, connect, set the title. Each cleanup runs before the next effect and when the component unmounts.
- JSX is HTML-like syntax that the build turns into plain function calls.
- The dependency array lists every value the effect reads; React re-runs the effect only when one of them changed.
- A missing dependency gives a stale closure: the effect keeps using an old value. oxlint and the hooks lint rule catch this.
- Cleanup runs before the next run and on unmount.
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.
- Hand out a draft produce(base, recipe) calls your function with a draft: a Proxy that stands in for the real data and notices every read and write.
- Record the edits You write ordinary code such as draft.todos[0].done = true. Immer copies an object only the first time you write to it (copy-on-write).
- Build the result When the function returns, touched objects become new copies and everything else is the very same object as before (structural sharing), so memoized components skip it.
- Freeze it The result is frozen by default, so a later accidental edit throws an error instead of silently rewriting the history.
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.
- Start at the entry Rolldown reads the entry script and follows every import, building a graph of all modules.
- Transform TypeScript and JSX become plain JavaScript and the CSS is collected. Plugins can rewrite code at this step.
- Tree-shake Exports nobody imports are removed. The sideEffects field in package.json tells the bundler which files may be dropped when nothing uses them.
- Split Each dynamic import() becomes its own chunk; big libraries can be grouped into named vendor chunks.
- Minify and hash The code is shrunk, and each file name gets a content hash - app-3fK9x2.js - that changes only when its content does.
- Write index.html The page is written last, pointing at the current hashed files, with preload links for what the first screen needs.
{{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.
- Vite with Rolldown A dev server plus a Rust bundler; Instant start and updates in development; Fast production builds; Vitest shares the same config; Some modes are still experimental; Development and production run different code paths
- webpack The long-time standard; The most mature plugin ecosystem; Fine-grained control; Slow on a large app; Heavy configuration
- Next.js A React framework with server rendering; Server rendering and search-friendly pages built in; File-based routing; Needs a Node server to run; Little gain for screens behind a sign-in
- esbuild on its own A very fast Go bundler; Extremely fast; Almost no configuration; No hot module replacement; You build the dev workflow yourself
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.
- Intent The pointer rests on a link. {{brand}} starts downloading that screen's code after a short pause, or at once on keyboard focus - unless the browser asked to save data.
- Click The router calls history.pushState: the address changes, the page does not reload.
- Match The router matches the new address to a route and its screen.
- Load The screen is a lazy component. If its chunk is not here yet, a Suspense fallback shows until it arrives.
- Guard A screen that needs an account shows a sign-in card in its place and keeps the address, so signing in lands exactly there.
- Back button The browser fires popstate, and the router shows the previous screen - often straight from the cache.
- Filters, tabs and search live in the query string, so a link someone shares opens exactly what they saw.
- A redirect keeps the query string, so a deep link is never half-followed.
- The web server must answer every app address with index.html; otherwise a refresh on /reports/42 is a 404.
- Hiding a menu item protects nothing: the server still checks every request.
- Closing the tab does not pass through the router, so a leave-without-saving guard also needs a beforeunload listener.
The options in the market
- React 19 Components and hooks, the largest ecosystem; The largest ecosystem and hiring pool; Mature libraries for data, routing and testing; Components are plain functions - easy to test; Only a view library: routing, data and forms are your choice; Effects and re-renders need discipline
- Angular A complete framework; Router, forms, HTTP and dependency injection included; Strong conventions for large teams; A steep learning curve: decorators, RxJS, dependency injection; Less freedom to pick libraries
- Vue 3 A progressive framework; A gentle learning curve; Fine-grained reactivity; A smaller job market than React; Two styles of writing components
- Svelte A compiler instead of a runtime; Very small bundles; Little boilerplate; A smaller ecosystem; Fewer large production apps to learn from
- Blazor WebAssembly C# in the browser; One language with a .NET backend; Shared types with the server; A large first download of the .NET runtime; A smaller UI ecosystem
- TanStack Query A cache for server data; Caching, deduplication, retries and refetching built in; Invalidation by key prefix; Optimistic updates with rollback; A new mental model: keys and staleness; Not meant for client-only state
- Redux Toolkit with RTK Query One store for everything; One place for all state; Excellent developer tools; More ceremony: slices, endpoints, tags; Tempts teams to copy server data into global state
- SWR A small fetch-and-cache hook; Tiny and simple; Less help for mutations and optimistic updates; A smaller ecosystem
- Apollo Client GraphQL with a normalised cache; Each entity cached once and shared; Excellent with a GraphQL API; Needs a GraphQL server; A normalised cache is hard to debug
- useEffect and useState by hand No library; Nothing to install; Race conditions, no cache, no deduplication; Every screen reinvents loading and error states
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.
- React 19 and strict TypeScript: the largest ecosystem and safe refactoring - a renamed field is caught in every screen at once.
- A single-page app, not server rendering: almost every screen is behind a sign-in, where search engines never look; public pages can be a separate, small entry.
- Vite with Rolldown: one configuration for the development server, the production build and the tests (Vitest).
- TanStack Query for server data: nearly all state is a copy of the server, so the library owns caching and freshness and the features do not.
- Redux only as an event bus: a few app-wide moments, such as the session changing, still need a broadcast.
- Rules enforced by scripts: layers, bundle size, contrast and interface rules fail the build, so they never depend on someone remembering. A separate accessibility sweep runs on the built app before releases.
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.
- Single-page app JavaScript draws everything; Instant moves between screens; state survives navigation; Simple static hosting; Bigger first download; Nothing without JavaScript; weak for search
- Server rendering (Next.js, Remix) The server sends ready HTML; Fast first paint; Good for search; Needs a Node server; Hydration cost: the page runs its code again to become interactive
- Static generation HTML built once, ahead of time; Cheapest hosting; Fastest; Only for content that rarely changes
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.
- A component asks useQuery({ queryKey: ['boards', id], queryFn }) runs during render.
- Cache lookup If the key is cached and still fresh, the data comes back at once - no request.
- Fetch If it is missing, one request goes out. Ten components asking for the same key share one request (deduplication).
- Fresh, then stale For the staleTime the data counts as fresh. After that it is still shown, but refetched on the next mount or when invalidated.
- Unused When no component uses the key any more, it waits for the gcTime, then leaves memory.
- Failed A failed request is retried; after that the hook reports an error the screen can show.
{{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.
- mutate(input) The component calls the mutation; isPending turns true, so the button can show it is busy.
- onMutate Runs before the request - the place for an optimistic change.
- The request mutationFn calls the API.
- onSuccess or onError Refresh what the write touched, or explain the failure.
- onSettled Runs either way. Usually invalidate the affected keys, so the server has the last word.
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.
- Cancel in-flight reads A late answer for the same key must not overwrite the patch, so cancelQueries comes first.
- Snapshot Keep the current data of every key the write will touch.
- Patch the cache setQueryData writes the new state; the screen updates at once.
- Send the request The real write runs behind the patched screen.
- Roll back on failure Put every snapshot back exactly, and tell the person why - with a Retry.
- Let the server win On settle, invalidate the keys, so the screen ends with the server's truth either way.
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.
- The person changes Sign-in, sign-out or an expired session.
- Clear the cache queryClient.clear() drops every cached answer, so the next person never sees the last person's data.
- Broadcast One session-changed action is dispatched to the store.
- Listeners react Listener middleware runs side effects, such as clearing private data held in memory.
- Remount The workspace is keyed by the person, so every screen starts fresh.
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.
- Parse The script reads every source file with the TypeScript compiler API, so it sees real imports, not text that looks like one.
- Resolve Each import is resolved the way the compiler does it, aliases included, to a real file and so to a layer.
- Apply the rules An upward import, a feature reaching into another, a view importing another view, a deep import past a feature's public entry - each is an error with its file and line.
- Guard the model Pure model code may not import React, the query library, the HTTP client or UI, so it stays testable without rendering anything.
- Find cycles A depth-first walk of the import graph reports any circular dependency with its full path.
- Fail the build Any finding exits with an error, so the rules hold in every pull request.
- Each feature exposes one public entry file; code outside the feature may import only that, never its inner files.
- Inside a feature, files import their neighbours directly and never their own entry - that is how import cycles begin.
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.
- First paint The entry script, its preloaded chunks and the stylesheet - just enough to draw the frame.
- The chosen entry The product or the public site, with what it imports at once and the text for the page's language.
- Vendor chunks Big, stable libraries - React, the state libraries, the editor, the charts - sit in named groups, so a deploy that changes one screen does not make anyone download them again.
- The screen The current screen's own chunk, plus whatever only it needs.
- Everything else, later Other screens, dialogs and panels load when opened - or a moment earlier, on hover intent.
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.
- Register The page registers the worker - in {{brand}} only from a production build, and only after the page has loaded, so it never competes with the first render.
- Install The worker stores what it wants to keep in the Cache Storage - here, one offline page and nothing else.
- Activate Old caches are deleted, navigation preload is switched on, and the worker takes control of open pages.
- Fetch For a page load the worker returns the network's answer; if the network fails, the offline page. Scripts and API calls are left to the browser.
- Push The worker can show a notification while the app is closed; a tap opens the right place in a window of the app.
- Update When the worker file changes by a single byte, the browser installs the new one. That is why the worker file must never be cached for long.
- Cache first Serve from the cache, fall back to the network; The fastest; Works fully offline; Can keep serving an old build after a deploy; Needs careful versioning
- Stale while revalidate Serve the cache, refresh it in the background; Fast and fairly fresh; Always one visit behind after a change
- Network first The network, the cache only when offline; Fresh when online; Old data offline; Keeps copies of pages and data on the device
- Network only, plus an offline page Never cache the app; one friendly page when offline; A deploy can never strand anyone on an old build; Nothing private stays on a shared computer; No offline work
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.
- ARIA Accessible Rich Internet Applications: attributes such as role, aria-label and aria-expanded that describe a control to assistive technology.
- Accessible name What a screen reader says for a control: its text, its label or its aria-label. An icon button without one is silent.
- Live region An element with aria-live: when its text changes, a screen reader reads it out without moving the focus.
- Focus management Moving the keyboard focus on purpose: into a dialog when it opens, back to its opener when it closes.
- inert An attribute that makes part of the page unclickable and unfocusable - for example the page behind an open drawer.
- WCAG The Web Content Accessibility Guidelines. Level AA is the usual target: contrast, keyboard access, names, focus.
- axe-core An open-source engine that runs WCAG rules against the live page and reports each violation with its impact.
- Open the real page Use a real browser (for example Playwright), sign in if needed, and open each main screen at phone, tablet and desktop widths and in every theme you ship.
- Run axe Run axe-core with the WCAG A and AA tags. Fail the run on serious or critical violations; list smaller ones as notes.
- Walk the keyboard Press Tab through the screen: every control is reached, and each shows a visible focus ring.
- Test the dialogs The focus moves in, Tab stays inside, Escape closes, and the focus returns to the opener.
- Watch layout shift and feedback The layout may shift only a tiny amount while it loads, and a click must show a response almost at once.
- Justify exceptions Keep exceptions in a list, and make the check refuse any entry without a written reason. Run the sweep in your pipeline or before each release.
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.
- Raw values per theme Light, Dark and High contrast each define the surfaces, four ink steps, the accents and the signal colours.
- The chosen accent An attribute picks one of six accents; it sets the brand colour and its hover.
- Density Comfortable or compact changes spacing tokens, never the components.
- Derived roles Tokens such as a hover wash are computed with color-mix() from the values above, so they read well on every theme.
- Utilities Tailwind's theme maps its colour names to the tokens, so a class like bg-card follows the theme by itself.
- A tiny script in the head Before the stylesheet paints, a small separate script reads the remembered choice from the browser's storage. A signed-in app also saves the choice to the account, but the first paint cannot wait for the network.
- System is resolved If the choice is System, the prefers-color-scheme media query decides Light or Dark.
- Attributes are set The theme, the accent and the density go on the html element, and the browser's theme colour for the title bar is set too.
- First paint The page draws in the right theme from its very first frame - no flash.
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.
- Build HubConnectionBuilder with the hub's address, cookies included, automatic reconnect and quiet logging.
- Listen connection.on(...) for each signal the screen cares about, each handler checking that the signal is about this record.
- Start, a tick later Start in a zero-delay timer, then join the group for this record. A mount that StrictMode undoes at once never starts a handshake.
- A signal arrives Ids that arrive close together are gathered for a few milliseconds and fetched as one request, then folded into the cached lists.
- Reconnect After a drop the client cannot know what it missed: it joins again and refetches what the screen shows.
- Permissions change The client asks the server again; if it is refused, the screen treats the record as gone.
- Unmount The cleanup leaves the group and stops the connection.
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.
- Create the project Run npm create vite@latest my-app -- --template react-ts, then npm install.
- Add TanStack Query npm install @tanstack/react-query, then wrap the app in a QueryClientProvider whose staleTime is one minute.
- Read and write Run a free local fake API with npx json-server db.json (it really saves writes). Fetch the list with useQuery and a keys object; add a mutation that invalidates the list and watch the new item appear after the refetch.
- Make it optimistic Patch the cache in onMutate and roll back in onError. To see the rollback, make mutationFn throw for one special item (for example: if (id === 13) throw new Error("nope")), or add networkMode: "always" to the mutation and then set the network to Offline in the developer tools.
- Split a screen Add React Router and load a second screen with lazy(); its chunk appears in the Network tab only when you open it.
- Measure it Run npm run build and the small script below, set a budget, and make the build fail when it is crossed.
- Check accessibility Run Lighthouse or the axe browser extension and fix what they report, then use the app with the keyboard only.
Common mistakes
- Copying server data into global state: two copies drift apart. Keep it in the query cache and invalidate after writes.
- Fetching in useEffect by hand: no cache, no deduplication, and a slow first answer can overwrite a fast second one. Use a query library.
- Flat query keys: one ['cards'] key for every board means a write refetches everything, or nothing. Build keys as a tree, from general to specific.
- Effects that never clean up: a connection or listener opened in useEffect must close in its cleanup - StrictMode shows the leak in development.
- Caching the service worker file: browsers keep running the copy they have. Serve it, and index.html, with no-cache.
- Clickable divs instead of buttons: no keyboard access and no name. Use a button, which gives focus, Enter, Space and a role for free.
Interview questions
- What is the difference between server state and client state? Server state is owned by the server and only cached in the browser, so it can go stale; client state exists only in the browser. Server state belongs in a cache such as TanStack Query with keys and invalidation; client state in component state, the URL or a small store.
- How does React decide what to change on the page? Rendering calls your components to produce a new tree; reconciliation compares it with the previous one using element types and keys; the commit writes only the differences to the DOM. Effects run after the browser paints.
- Why do list items need a key, and why not the array index? The key tells React which item is which between renders. With the index, inserting at the top shifts every key, so React reuses the wrong elements and their state - an input keeps the wrong text.
- When should you not use useEffect? When the value can be computed during render, or the work belongs in an event handler. Effects are for syncing with something outside React: a connection, a subscription, the document title.
- What is the dependency array of useEffect for? It lists the values the effect reads; React re-runs the effect only when one changes. A missing value causes a stale closure that keeps using old data, and a changing object or function reference causes needless reruns.
- How do you implement an optimistic update safely? Cancel in-flight queries for the keys, snapshot them, patch the cache, then send the request. On error restore the snapshots; on settle invalidate, so the server has the last word.
- What is code splitting, and how do you stop the bundle growing back? Each screen is a dynamic import, so it becomes its own chunk loaded on demand. A budget per entry and per route in CI fails the build when something grows, and raising a line needs a written reason.
- What does a service worker do, and what is the risk with caching? It sits between the page and the network and can answer requests, work offline and show push notifications. A cache-first worker can keep serving an old build after a deploy, so the worker file must never be long-cached and every cache must be versioned.
- Single-page app or server rendering? A SPA suits an app behind a sign-in: instant moves and simple hosting, at the cost of a bigger first download and weak search visibility. Server rendering suits public, content-heavy pages that must rank and paint fast.
- How do live updates work together with React Query? A WebSocket - here SignalR - sends a small signal naming what changed, and the client invalidates those query keys so React Query refetches them. On reconnect it refetches what the screen shows, because missed signals cannot be known.
Related
- /help/technology/realtime-events
- /help/technology/testing
- /help/technology/translations