| name | vite-react |
| description | Best practices for Vite + React + TypeScript web applications. Covers feature-first project structure, build/lint validation workflows, Vite configuration, and single-file build for embedding. For service layer patterns, domain models/DTOs, React Query integration, and error handling, see the `ioc-development-pattern` skill. For Fluent UI v9 component patterns, styling, and theming, see the `ui-fluentui-react` skill. Triggers on "vite", "react app", "feature structure", "vite build", "react typescript", "single file build", "vite config". |
| user-invocable | false |
Vite + React + TypeScript Best Practices
You are an expert in building modern web applications with Vite, React, and TypeScript. These patterns apply to any Vite+React project — whether standalone, embedded in Power Platform, or deployed elsewhere.
Project Scaffolding with create-vite
Always use the official create-vite scaffolder to initialize new projects. Do NOT manually create project files (package.json, tsconfig, vite.config.ts, etc.) — use the scaffolder to get the latest official template.
Interactive CLI Requirement
create-vite v6+ uses interactive prompts (@clack/prompts) that require arrow-key navigation and cannot be driven by non-interactive terminals. The run_in_terminal tool with isBackground: false cannot send keypresses to these menus, so the command will hang and exit silently with code 1.
Solution: Launch as a background terminal and let the user interact.
-
Clean the target directory (if needed) using run_in_terminal with isBackground: false:
# Remove existing scaffold files if re-scaffolding
Remove-Item -Force eslint.config.js, index.html, package.json, ... -ErrorAction SilentlyContinue
-
Launch create-vite in a background terminal so the user can interact with the prompts:
run_in_terminal:
command: npm create vite@latest . -- --template react-ts
isBackground: true
-
Tell the user to switch to the terminal pane and complete the interactive steps (select framework, variant, package name, etc.).
-
After the user confirms completion, verify the scaffold by listing files:
Get-ChildItem -Force | Select-Object Name
-
Run npm install (non-background):
npm install
-
Verify the build:
npm run build
Key Rules
- Never skip the scaffolder — it produces the latest recommended config (tsconfig, eslint, vite.config) which evolves across Vite versions
- Never use
isBackground: false for create-vite — it will hang on the interactive prompts
- Never pin to older
create-vite versions (e.g., npm create vite@5) just to avoid prompts — always use @latest
- Always verify with
npm run build after scaffolding to confirm the template is working
Feature-First Project Structure
Organize code by feature, not by file type. Each feature owns its models, services, hooks, and components:
src/
├── features/
│ ├── accounts/
│ │ ├── models/
│ │ │ └── Account.ts # Domain model types and DTOs
│ │ ├── services/
│ │ │ └── AccountService.ts # Service layer wrapping API calls
│ │ ├── hooks/
│ │ │ └── useAccounts.ts # React Query hooks
│ │ └── components/
│ │ ├── AccountList.tsx # List view
│ │ ├── AccountDetail.tsx # Detail view
│ │ └── AccountForm.tsx # Create/edit form
│ ├── contacts/
│ │ ├── models/
│ │ ├── services/
│ │ ├── hooks/
│ │ └── components/
│ └── shared/
│ └── components/ # Cross-feature UI components
├── App.tsx
└── main.tsx
Why feature-first?
- Collocated code is easier to navigate and maintain
- Features can be moved, renamed, or deleted as a unit
- Avoids giant
components/, hooks/, services/ folders
- Clear ownership boundaries between features
Terminal Working Directory Discipline
When working in a multi-project workspace (e.g., code-app/ and power-pages/ as siblings),
ALWAYS use absolute or explicit relative paths at the start of terminal commands:
# GOOD — explicit directory
Set-Location c:\src\MyWorkspace\code-app; npm run build
# BAD — assumes current directory
npm run build
Rules:
- At the start of every terminal command, set the working directory explicitly
- Never assume the terminal is "still" in the directory from the last command
- If a command fails with "file not found", check the current directory first
Dev Server Lifecycle Management
Before starting a dev server (npm run dev, vite, etc.):
-
Kill any existing instances:
Get-Process -Name "node" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'vite' } |
Stop-Process -Force
-
Use --strictPort to prevent port cascading:
export default defineConfig({
server: {
port: 5173,
strictPort: true,
},
});
-
After browser testing, kill the dev server explicitly. Don't leave zombie processes.
Service Layer, React Query & Error Handling
Use the ioc-development-pattern skill for the complete service layer pattern — service interfaces, domain models with DTOs (CreateDto/UpdateDto), in-memory implementations for local development, service registry, React Query (TanStack Query) integration with query key factories, cache invalidation, mutation hooks, optimistic updates, real backend service implementations with error handling, and incremental backend migration. That skill is the single source of truth for data access patterns.
State Management Strategy
Most state in a data-driven business app is server state (fetched data). Use this hierarchy:
Server State → React Query (TanStack Query)
Managed by the ioc-development-pattern skill. React Query handles:
- Fetching, caching, and synchronizing server data
- Background refetching and cache invalidation
- Loading/error/success states
- Optimistic updates for mutations
This covers 80-90% of state in a typical business app.
UI State → React Context + useState
For cross-component UI state that doesn't come from the server:
| State Type | Approach | Example |
|---|
| Component-local | useState | Form inputs, open/close toggles, selected tab |
| Feature-scoped | useState lifted to parent | Selected list item, filter state |
| App-wide UI | React Context | Theme preference, sidebar collapsed, current user display |
| URL state | URL search params | Active filters, selected tab, page number |
import { createContext, useContext, useState, type ReactNode } from "react";
interface UIState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
}
const UIContext = createContext<UIState | null>(null);
export function UIProvider({ children }: { children: ReactNode }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
return (
<UIContext.Provider value={{
sidebarCollapsed,
toggleSidebar: () => setSidebarCollapsed(prev => !prev),
}}>
{children}
</UIContext.Provider>
);
}
export function useUI() {
const ctx = useContext(UIContext);
if (!ctx) throw new Error("useUI must be within UIProvider");
return ctx;
}
What NOT to Do
- Don't use Redux/Zustand for server state — React Query does this better with caching, invalidation, and background sync
- Don't put form state in global stores — Keep it in the form component with
useState
- Don't duplicate server data in local state — Use React Query's cache as the source of truth
- Don't create a context for every feature — Only lift state up when multiple unrelated components need it
- Exception: Zustand IS appropriate for cross-cutting UI concerns like the notification queue
(see
ui-fluentui-react skill → Mandatory Shell Scaffold). The notification store bridges
service/hook layer → React toast UI, which React Context alone cannot do cleanly.
Decision Guide
Is this data from the server? → React Query (via ioc-development-pattern hooks)
Is this component-local? → useState
Do siblings need to share it? → Lift state to parent
Do unrelated components need it? → React Context
Does it belong in the URL? → URL search params (useSearchParams)
Is it a cross-cutting notification queue? → Zustand (see notification store pattern)
Fluent UI v9 Component Patterns
Use the ui-fluentui-react skill for all Fluent UI v9 guidance — component patterns (list views, detail views, forms, dialogs, drawers, data grids, toasts), makeStyles styling with design tokens, FluentProvider setup, theming, layout patterns, toolbar/navigation, and the business component checklist (loading/error/empty/disabled states). That skill is the single source of truth for Fluent UI React v9.
NON-NEGOTIABLE: Routing — Catch-All 404 Route
Every React Router app must include a catch-all route that shows a "Not Found" screen.
Never redirect unknown paths to / silently — this masks broken links and confuses users.
The route tree MUST be wrapped in <ErrorBoundary> and <Suspense>:
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { Spinner } from "@fluentui/react-components";
import { ErrorBoundary } from "./shared/components/ErrorBoundary";
import { NotificationBridge } from "./shared/components/NotificationBridge";
export default function App() {
return (
<ErrorBoundary>
<NotificationBridge />
<Suspense fallback={<div style={{ display: "flex", justifyContent: "center", padding: "48px" }}><Spinner label="Loading..." size="medium" /></div>}>
<Routes>
<Route path="/" element={<AppShell />}>
<Route index element={<DashboardScreen />} />
<Route path="projects" element={<ProjectListScreen />} />
<Route path="projects/new" element={<ProjectNewScreen />} />
<Route path="projects/:id" element={<ProjectDetailScreen />} />
{/* ... other routes ... */}
{/* ✅ Catch-all — shows 404 page */}
<Route path="*" element={<NotFoundScreen />} />
</Route>
</Routes>
</Suspense>
</ErrorBoundary>
);
}
ErrorBoundary and Suspense are mandatory. Without ErrorBoundary, a single
component crash takes down the entire app. Without Suspense, any React.lazy() import
or suspense-enabled data fetch crashes the app with an unhandled promise.
See ui-fluentui-react skill → "Mandatory Shell Scaffold" for the full required structure.
import { Button, Text, tokens, makeStyles } from "@fluentui/react-components";
import { useNavigate } from "react-router-dom";
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
gap: tokens.spacingVerticalL,
padding: tokens.spacingHorizontalXXL,
},
});
export function NotFoundScreen() {
const navigate = useNavigate();
const styles = useStyles();
return (
<div className={styles.root}>
<Text size={800} weight="bold">404</Text>
<Text size={400}>Page not found</Text>
<Button appearance="primary" onClick={() => navigate("/")}>
Go to Dashboard
</Button>
</div>
);
}
Rules:
<Route path="*"> must be the last child inside the layout route
- Never use
<Navigate to="/" replace /> as a catch-all — this silently hides broken routes
- The 404 screen should offer navigation back to the dashboard or home
Build & Lint Validation Workflow
Run these checks after every significant code change and ALWAYS before deployment:
Step 1: TypeScript Compilation
npm run build
- Must exit with code 0
- Check for: type mismatches, missing imports, undefined properties, generic type errors
Step 2: ESLint Check
npm run lint
- Must exit with code 0
- Check for: unused variables, missing
useEffect dependencies, incorrect hook usage
Step 3: Auto-Fix (if issues found)
npm run lint -- --fix
Auto-fixable: import sorting, semicolons, whitespace, unused imports, quote style.
Non-fixable (manual): logic errors, type errors, complex rule violations.
Recommended package.json Scripts
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
}
}
Vite Configuration Best Practices
Base Path for Embedded/Hosted Deployments
When deploying to a non-root path (e.g., Power Platform, SharePoint, subdirectory hosting):
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-fluent': ['@fluentui/react-components', '@fluentui/react-icons'],
'vendor-query': ['@tanstack/react-query'],
},
},
},
},
});
Manual chunk splitting is required. Without it, the entire app bundles into a single
large JS file. Splitting React, Fluent UI, and React Query into separate vendor chunks
enables browser caching — vendor chunks rarely change, so users only re-download app code
on updates. This can reduce reload times by 60-80% after the first visit.
Without base: './', built HTML references assets with absolute paths (/assets/index.js) which break when deployed to a non-root URL.
Verify After Build
cat dist/index.html | grep -E 'src=|href='
Single-File Build for Embedding
When you need the entire app in a single HTML file (e.g., for web resources or iframes):
npm install -D vite-plugin-singlefile
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
assetsInlineLimit: 100000000,
cssCodeSplit: false,
},
});
Produces a single dist/index.html with all JS/CSS inlined.