Mert Özen Further With Every Line
Frontend React — #02

Your First React Project: Vite + TypeScript, Project Structure, StrictMode and React DevTools

Mert Özen Nov 15, 2026 12 min 4 views
Your First React Project: Vite + TypeScript, Project Structure, StrictMode and React DevTools

We build a project skeleton for the series with Vite's react-ts template, feature folders, an @/ alias from a single source via Vite 8's resolve.tsconfigPaths, Oxlint's hook rules and a dev proxy that forwards /api requests to the Bookstore API. With real output we show that StrictMode renders twice and runs effects setup-cleanup-setup in development while the production build runs everything once, and that without the proxy the browser refuses to read the response because of CORS.

Version note: The examples in this post were built and run with React 19.3, TypeScript 7.0, Vite 8.3, Oxlint 1.93 and Node.js 24; API calls went to a local copy of Bookstore.Api (ASP.NET Core 10), and browser behaviour was observed with Playwright 1.65 in headless Chromium.

Predict First

In the first post we created the bookstore-react project with Vite for Bookstore, the online bookstore used as the shared example across this site's series, and wrote a book card. Now we add a console.log and a small effect at the top of the card:

export function BookCard({ book, quantity, onAdd }: BookCardProps) {
  console.log(`render BookCard ${book.title}`);

  useEffect(() => {
    console.log(`effect: subscribe ${book.isbn}`);
    return () => console.log(`cleanup: unsubscribe ${book.isbn}`);
  }, [book.isbn]);

We open the page with npm run dev and look at the console without touching anything. How many lines do you see, and in what order? Then we click the button once: how many new lines? Does the result change when we open the same code with npm run build and npm run preview? The answers are in the StrictMode section, with the real console output of both environments.

In this post we get the project into a shape that can grow through the series: the files the template brings, feature folders, the @/ path alias, Oxlint's hook rules, StrictMode, React DevTools and Vite's dev proxy for reaching the Bookstore API.

Why Vite and Not a Framework?

react.dev recommends starting a new app with a framework: Next.js (App Router), React Router's framework mode, or Expo for mobile. These frameworks bring routing, data loading, code splitting and server rendering when needed, all together. The same docs also describe starting from scratch with Vite, Parcel or Rsbuild as a separate path for people who want to learn or build their own setup.

In this series we build a single-page app with Vite. The reason is about teaching: an Angular reader is used to creating a single-page app with ng new, and Bookstore's Angular client is one too. Building the same architecture in React lets us compare the two worlds piece by piece, without new concepts such as server rendering, Server Components and hosting differences blurring the comparison. We add routing with React Router 8's data mode and cover Next.js in a separate post at the end of the series. In a real project this is the team's call: for a site that needs SEO and a first load coming from the server, a framework may be the better choice.

What the Template Gives You

The entry point of the project generated by npm create vite@latest bookstore-react -- --template react-ts is index.html. It holds a single <div id="root"> and a module script that loads /src/main.tsx. This is what <app-root> in index.html and bootstrapApplication in main.ts are in Angular:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

createRoot hands the inside of the given DOM node over to React; render triggers the initial render. From then on, everything inside that div is React's responsibility. The ! at the end tells TypeScript that getElementById does not return null here; we wrote index.html ourselves, so the assumption is safe.

The other files: vite.config.ts holds the Vite settings and the React plugin (@vitejs/plugin-react 6.3, which uses Oxc for the JSX transform and Fast Refresh). tsconfig.app.json holds the type settings for the app code, tsconfig.node.json those for vite.config.ts; the root tsconfig.json links them as project references and tsc -b builds both. The "jsx": "react-jsx" setting says JSX is compiled with the automatic runtime, which is why files do not need an import React from 'react' line. "noEmit": true because Vite, not TypeScript, produces the JavaScript; tsc only type-checks.

Feature Folders and a Path Alias

The template puts everything flat under src/. Since we will add catalog, cart, order and authentication screens during the series, we split the code by business area rather than by technical kind (components/, hooks/). At the end of this post the project looks like this:

bookstore-react/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
├── .oxlintrc.json
├── public/
│   └── favicon.svg
└── src/
    ├── main.tsx
    ├── App.tsx
    ├── index.css
    └── features/
        ├── cart/
        │   └── CartSummary.tsx
        └── catalog/
            ├── BookCard.tsx
            └── types.ts

The JSON shapes returned by the Bookstore API live in features/catalog/types.ts, with the same field names as api-models.ts in the Angular client:

// Shapes returned by Bookstore.Api (System.Text.Json uses camelCase).
export type BookSummary = {
  isbn: string;
  title: string;
  author: string;
  category: string;
  price: number;
  publishedYear: number;
};

export type CategoryView = {
  name: string;
  slug: string;
};

export type PagedResult<T> = {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
};

As folders get deeper, relative imports such as ../../features/catalog/types become hard to read. On the TypeScript side we add a paths mapping to tsconfig.app.json:

    "paths": {
      "@/*": ["./src/*"]
    },

We did not write a baseUrl; the paths inside paths are resolved relative to the folder of the tsconfig file. On the Vite side you used to write the same mapping a second time with resolve.alias or install the vite-tsconfig-paths plugin. Vite 8 has a resolve.tsconfigPaths option; turn it on and Vite reads the mapping from tsconfig:

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// Bookstore.Api address for the dev proxy (README default: http://localhost:5080)
const apiTarget = process.env.BOOKSTORE_API ?? 'http://localhost:5080'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    // '@/...' imports come from "paths" in tsconfig.app.json
    tsconfigPaths: true,
  },
  server: {
    proxy: {
      // /api/books -> http://localhost:5080/api/books, same origin for the browser
      '/api': { target: apiTarget, changeOrigin: true },
    },
  },
})

We explain the server.proxy setting in the second half of the file in the API section. Imports now look like import { BookCard } from '@/features/catalog/BookCard', and when a file moves only that line changes. We leave the extension out; imports with an extension, such as ./App.tsx in the template's own main.tsx, also work thanks to allowImportingTsExtensions.

Oxlint and the Hook Rules

The React template of create-vite 9 comes with Oxlint instead of ESLint: written in Rust, configured in a single .oxlintrc.json file. The template turns on two React rules; we added two more:

{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "plugins": ["react", "typescript", "oxc"],
  "rules": {
    "react/rules-of-hooks": "error",
    "react/exhaustive-deps": "warn",
    "react/jsx-key": "error",
    "react/only-export-components": ["warn", { "allowConstantExport": true }]
  }
}

rules-of-hooks checks that hooks are called in the same order on every render. React matches a component's state by call order, not by name, so putting a useState inside an if or behind an early return makes the states swap places on a later render. exhaustive-deps checks whether the reactive values used inside an effect are in its dependency array. A component that breaks both on purpose:

import { useEffect, useState } from 'react';

export function CartBadge({ cartId }: { cartId: string | null }) {
  if (cartId === null) {
    return null;
  }
  // Called after an early return: the order of hooks changes between renders
  const [count, setCount] = useState(0);

  useEffect(() => {
    fetch(`/api/carts/${cartId}`)
      .then((response) => response.json())
      .then((cart: { items: unknown[] }) => setCount(cart.items.length));
  }, []);

  return <span>{count}</span>;
}

The output of npm run lint:

> bookstore-react@0.0.0 lint
> oxlint

src/features/cart/CartBadge.tsx:8:29: error react-hooks(rules-of-hooks): React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render.
    help: Move the Hook call before the condition, or call it unconditionally and branch inside the Hook/effect instead.
src/features/cart/CartBadge.tsx:11:25: warning react-hooks(exhaustive-deps): React Hook useEffect has a missing dependency: 'cartId'
    help: Either include it or remove the dependency array.
src/features/cart/CartBadge.tsx:10:3: error react-hooks(rules-of-hooks): React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render.
    help: Move the Hook call before the condition, or call it unconditionally and branch inside the Hook/effect instead.
exit code: 1

The hook rules are errors and the dependency rule is a warning, so the command exits with 1 and can stop the build in CI. On a clean project Oxlint prints nothing and exits with 0. The React team's own lint package, eslint-plugin-react-hooks (7.1), is still the reference for projects on ESLint; Oxlint contains its own implementation of the same rules. We look at the logic of the dependency array in detail in the sixth post.

StrictMode: Running Things Twice on Purpose in Development

<StrictMode> in main.tsx turns on extra checks, in development only, for the tree below it. The console Playwright collected when the card from "Predict First" was opened with npm run dev and clicked once:

render BookCard Dune
render BookCard Dune
effect: subscribe 9780441172719
cleanup: unsubscribe 9780441172719
effect: subscribe 9780441172719
--- click
render BookCard Dune
render BookCard Dune

The same code with npm run build and npm run preview:

render BookCard Dune
effect: subscribe 9780441172719
--- click
render BookCard Dune

In development every render happens twice, and the effect runs setup, cleanup, setup again. In production everything happens once. This is not a bug but a deliberate test: according to react.dev's StrictMode page, React calls the functions that should be pure (component bodies, state initializers, updaters) twice in development and sets up and cleans up every effect one extra time. A pure function gives the same result when run twice; a function that does a push into an array or increments a global counter during render gives itself away on the second call. The same logic applies to effects: a subscription, interval or connection with a missing cleanup stays open twice in the mount, unmount, mount cycle, and you see the problem from day one.

The closest thing on the Angular side is the extra change detection check in development mode (ExpressionChangedAfterItHasBeenCheckedError): it also runs only in development and asks the same question, "does this calculation give the same result when done twice?". The difference is that React does not just warn, it really runs the code twice. So turning StrictMode off does not fix the problem, it only hides it. If React DevTools is installed, console.log lines from the second render appear dimmed; DevTools also has a setting, off by default, that hides them completely.

React DevTools

React DevTools ships as a browser extension for Chrome, Firefox and Edge and adds two panels to the developer tools on a page that uses React. The Components panel shows the component tree instead of the DOM tree: you can read and edit the props, state and hooks of the selected component. If you have used the component tree in Angular DevTools, this panel will feel familiar. The Profiler panel records an interaction and shows which component rendered how many times, how long it took and why it rendered; we use it for measurement in the performance post.

Since we collected this post's output in a headless browser, the extension was not run here; this section is based on react.dev's React Developer Tools page. Install the extension in your own browser, open the page with npm run dev, select App in the Components panel, and you can see the useState value and change it from there to watch the card's label change.

Reaching the API: Dev Proxy or CORS?

According to its README, the Bookstore API runs at http://localhost:5080 in development, while Vite's dev server uses 5173 by default. For the browser these are two different origins. If the app calls fetch('http://localhost:5080/api/books'), the browser applies the CORS rules and expects the API to send an Access-Control-Allow-Origin header. The Bookstore API has no CORS setup, because the Angular client uses a proxy as well.

There are two options: add a CORS policy to the API that allows the development origin, or let the dev server forward /api requests to the API. The Angular client's proxy.conf.json chose the second path; we do the same with Vite. In the vite.config.ts above, every request starting with /api goes to apiTarget; the browser only talks to its own origin. We made the target configurable with the BOOKSTORE_API environment variable; in this post's runs the API was on 127.0.0.1:52141 and the dev server on 52142:

BOOKSTORE_API=http://127.0.0.1:52141 npm run dev -- --host 127.0.0.1 --port 52142

On Windows with PowerShell, run $env:BOOKSTORE_API = "http://127.0.0.1:52141" first and then npm run dev; if you run the API on 5080 as in the README, you do not need the variable at all. The dev server's output:

> bookstore-react@0.0.0 dev
> vite --host 127.0.0.1 --port 52142

  VITE v8.3.8  ready in 52 ms

  ➜  Local:   http://127.0.0.1:52142/

The categories through the proxy:

curl -i http://127.0.0.1:52142/api/categories
HTTP/1.1 200 OK
Vary: Origin
connection: close
content-type: application/json; charset=utf-8
server: Kestrel
Transfer-Encoding: chunked

[{"name":"Classics","slug":"classics"},{"name":"Dystopian","slug":"dystopian"},{"name":"Fantasy","slug":"fantasy"},{"name":"Science Fiction","slug":"science-fiction"}]

The server: Kestrel header shows the response came from ASP.NET Core. To see the difference in the browser, we sent two requests with Playwright from the page on 52142: one to the proxy, one directly to the API.

fetch /api/categories (proxy): 200
console error: Access to fetch at 'http://127.0.0.1:52141/api/categories' from origin 'http://127.0.0.1:52142' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
console error: Failed to load resource: net::ERR_FAILED
fetch http://127.0.0.1:52141/api/categories (direct): TypeError: Failed to fetch

The direct request reached the API and the API returned 200 (calling the same address with curl gives a response), but the browser did not hand the response to JavaScript. CORS is not a server firewall; it is a reading rule enforced by the browser. With the proxy the rule never applies, because in the browser's eyes the request goes to the same origin. Fetching data properly and handling loading and error states are the topics of the sixth and eighth posts; for now we know the connection works.

In Production

Vite's proxy exists only during npm run dev. npm run build produces static files; in production, either a reverse proxy on the same origin (IIS, nginx, YARP; the site's API Gateway and YARP series covers this) or a CORS policy in the API must make sure /api requests reach the API. Using relative /api/... addresses in the app code lets the same code run in both environments.

Variables such as BOOKSTORE_API are read only in vite.config.ts, on the Node.js side; they do not end up in the code sent to the browser. To pass values to browser code Vite has the import.meta.env mechanism, and every value that goes there becomes a string anyone can read in the build output; we cover that, and why secrets do not belong there, in the end-to-end app post.

You do not need to turn StrictMode off in production; its checks only run in development anyway. Run lint in CI: npm run lint exits with 1 on a hook rule violation, which is enough to stop the pipeline.

Common Pitfalls

The most common reaction is to treat StrictMode's double run as a bug and delete <StrictMode>. A double log line or a request sent twice points to a missing cleanup or an impure render in the code; that is what needs fixing.

The second pitfall is defining the path alias on one side only. Write paths in tsconfig without telling Vite and tsc passes but the dev server fails with "Failed to resolve import"; the other way round, the editor and tsc show red squiggles. In Vite 8, resolve.tsconfigPaths: true reduces this to a single source.

The third pitfall is writing the API's full address into the code: fetch('http://localhost:5080/api/books'). That line bypasses the proxy, puts the browser back under the CORS rule and ties the code to one development setup. Keep relative /api/... addresses in the app code; let Vite decide where they go in development and a reverse proxy in production.

The last pitfall is organizing code by technical kind: components/, hooks/, utils/. It looks harmless in a small project; in an app with twenty screens a cart change means opening files in four folders. Feature folders and how to protect their boundaries were covered for Angular in "Boundaries in Large Angular Apps: Feature Folders, Facades and Layers" in the UI Architecture Patterns series; the same idea holds in React.

A Small Experiment

★☆☆ Import features/cart/CartSummary.tsx with a relative path instead of @/, then move CartSummary under features/shared/. Which imports did you have to fix?

★★☆ Remove the cleanup function from the effect in "Predict First" and open the page with npm run dev. What changed in the console? If this effect opened a real WebSocket subscription, what would happen in production, and what in development?

★★★ Add a CORS policy to the Bookstore API that allows the development origin (see the CORS material in the ASP.NET Core Web API series), turn off the Vite proxy and call fetch with the full address in the app. How many requests do you see in the browser's network tab? When is an OPTIONS request sent?

The project skeleton is ready. In the next post we see what JSX compiles to, props, composition with children and list rendering, and, with a real bug, why a key should not be the array index.