Instruction file imported from Woodside-Sandbox/dave-test-msal (
.github/instructions/frontend.instructions.md). Copyright stays with the author.
Frontend Dev Instructions
For AI Agents: React/component standards for apps/web-application/. For project overview, see copilot-instructions.md. For TypeScript rules, see typescript.instructions.md.
Persona: Experienced React Developer. You build production-ready SPAs following modern React patterns, strict TypeScript, and TanStack libraries exclusively.
Tech Stack (Non-Negotiable)
- Framework: React 19 + TypeScript + Vite
- Auth: MSAL (@azure/msal-browser, @azure/msal-react)
- Styling: Tailwind v4 + Flowbite React
- Forms: TanStack Form + Zod
- Data: TanStack Query
- Routing: TanStack Router
- Tables: TanStack Table
- Testing: Vitest + React Testing Library
Never use: MUI, Mantine, SWR, RTK Query, React Router, React Hook Form, Formik
Project Structure
apps/web-application/src/
├── assets/ # static files
├── authentication/ # MSAL config, utilities
├── components/ # generic reusables only
├── features/ # feature-based (most code here)
│ └── [feature]/
│ ├── components/
│ ├── hooks/
│ ├── queries/
│ └── utils/
├── hooks/ # shared hooks (2+ features)
├── providers/ # context/theme providers
├── queries/ # shared TanStack Query hooks
├── routes/ # TanStack Router (file-based)
├── utils/ # shared utilities
├── main.tsx # entry point
└── index.css # tailwind import + custom styles
Path Aliases: @/ → src/ (e.g., @/components/, @/features/, @/hooks/, @/utils/, @/authentication/)
Naming: kebab-case for all files
Architecture
- Feature-based: code in
features/[name]/unless used in 2+ features - Single responsibility per component
- Composition over inheritance
- No premature abstraction
Security & Auth
- Use
useAuthenticatedFetchfor API calls in React components; useacquireTokendirectly in non-React contexts (e.g., provider setup) - Never bypass auth checks in
__root.tsx - Never store tokens manually (MSAL handles)
- Client auth = UX only; enforce server-side
- Validate + sanitize all user input
- No hardcoded secrets or URLs
Note: useAuthenticatedFetch is a React hook that internally uses acquireToken (see authenticated-fetch.ts). Use acquireToken when outside React contexts, such as in custom hooks or initialization code.
Environment Variables
Access via import.meta.env. Prefix with VITE_ to expose to client.
// .env
VITE_API_URL=https://api.example.com
// Usage
const apiUrl = import.meta.env.VITE_API_URL
- Never commit
.envfiles with secrets - Use
.env.examplefor documentation - Access only
VITE_*prefixed vars (others not exposed)
UI Components (Order of Preference)
- Flowbite React - check library first
- Compose Flowbite React - combine existing components
- Custom + Tailwind - only when Flowbite insufficient
Flowbite React Syntax
Always use flat named exports. Never use dot-notation sub-components.
// Wrong
import { Accordion } from "flowbite-react";
<Accordion.Panel><Accordion.Title>T</Accordion.Title></Accordion.Panel>
// Correct
import { Accordion, AccordionPanel, AccordionTitle, AccordionContent } from "flowbite-react";
<AccordionPanel><AccordionTitle>T</AccordionTitle></AccordionPanel>
Applies to all compound components: Navbar, Sidebar, Dropdown, Card, Timeline, Footer, Breadcrumb, ButtonGroup, Tabs, Rating, etc.
Docs: https://flowbite-react.com/llms.txt — per-component markdown at https://flowbite-react.com/docs/components/[name].md
Tailwind v4
- Config in CSS via
@theme(notailwind.config.js) - Single
@import "tailwindcss"insrc/index.css - Always support light + dark:
dark:prefix - Mobile-first:
sm:,md:,lg:,xl: - Use
cn()for conditional classes - Never hardcode colors
- Classes are sorted by Biome's
useSortedClassesrule — runpnpm check:writeto auto-fix (notpnpm format)
import { cn } from '@/utils/cn'
<div
className={cn(
'rounded-lg px-4 py-2 font-medium',
'dark:bg-gray-800 dark:text-white',
variant === 'primary' && 'bg-brand-500 text-white',
isDisabled && 'opacity-50 cursor-not-allowed'
)}
/>
Routing
File-based in src/routes/. After changes: pnpm generate:routes
export const Route = createFileRoute('/admin')({
beforeLoad: async () => {
if (!hasPermission()) throw redirect({ to: '/' })
},
component: RouteComponent,
})
function RouteComponent() {
return <AdminPage />
}
createRouterLink utility for Flowbite React:
import { createRouterLink } from '@/utils/create-router-link'
import { SidebarItem } from 'flowbite-react'
const SidebarLink = createRouterLink(SidebarItem)
<SidebarLink to="/dashboard" icon={HiChartPie}>Dashboard</SidebarLink>
Route Params
TanStack Router provides full type safety for route params.
// Route definition with params
export const Route = createFileRoute('/users/$userId')({
component: UserPage,
})
// Accessing typed params
function UserPage() {
const { userId } = Route.useParams()
return <UserProfile id={userId} />
}
// Search params with validation
export const Route = createFileRoute('/search')({
validateSearch: z.object({
query: z.string().optional(),
page: z.number().default(1),
}),
component: SearchPage,
})
function SearchPage() {
const { query, page } = Route.useSearch()
return <SearchResults query={query} page={page} />
}
Forms
TanStack Form + Zod only.
import { useForm } from '@tanstack/react-form'
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
import { Label, TextInput, HelperText } from 'flowbite-react'
const emailSchema = z.object({
email: z.string().email('Invalid email'),
})
const form = useForm({
defaultValues: { email: '' },
validators: {
onChange: emailSchema,
},
validatorAdapter: zodValidator(),
onSubmit: async ({ value }) => await submitForm(value),
})
<form.Field
name="email"
children={(field) => (
<>
<Label htmlFor="email">Email</Label>
<TextInput
id="email"
type="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
color={field.state.meta.errors.length > 0 ? 'failure' : undefined}
aria-invalid={field.state.meta.errors.length > 0}
aria-describedby={field.state.meta.errors.length > 0 ? 'email-error' : undefined}
/>
{field.state.meta.errors.length > 0 && (
<HelperText id="email-error" color="failure">
{field.state.meta.errors[0]}
</HelperText>
)}
</>
)}
/>
Data Fetching
TanStack Query for all server state.
import { useQuery } from '@tanstack/react-query'
import { useAuthenticatedFetch } from '@/authentication/authenticated-fetch'
const useProfile = () => {
const fetch = useAuthenticatedFetch()
return useQuery({
queryKey: ['profile'],
queryFn: () => fetch('/api/profile'),
staleTime: 5 * 60 * 1000,
})
}
Tables
TanStack Table for data tables and grids.
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'
import { Table, TableHead, TableBody, TableRow, TableHeadCell, TableCell } from "flowbite-react";
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
<Table>
<TableHead>
{table.getHeaderGroups().map(group => (
<TableRow key={group.id}>
{group.headers.map(header => (
<TableHeadCell key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHeadCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
React Hooks
- Functional components only
- Proper dependency arrays
- Return cleanup functions in
useEffect - No inline functions in JSX when avoidable
Performance
React.memofor frequent re-rendersuseMemo/useCallbackwhen needed- Lazy load routes
- TanStack Pacer for timing control (debounce, throttle, rate limiting)
- Tree-shakeable imports
Error Handling
- Try/catch all async ops
- Handle with state (loading, error, success)
- Error boundaries for graceful degradation
Accessibility
<Label htmlFor="email">Email</Label>
<TextInput
id="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <HelperText id="email-error">{errors.email}</HelperText>}
Testing
import { render, screen } from '@testing-library/react'
import { expect, test } from 'vitest'
test('renders profile dropdown', () => {
render(<ProfileDropdown displayName="John Doe" username="jdoe" initials="JD" />)
expect(screen.getByText('John Doe')).toBeTruthy()
})
Review Checklist & Red Flags
Type Safety
- No
anytypes (useunknown+ guards or proper types) - Explicit return types on functions
- Proper null/undefined handling
- All props typed properly
Red flags: any usage, missing return types
Security & Auth
-
useAuthenticatedFetchused for API calls in React components;acquireTokenused elsewhere - No manual token storage (MSAL handles it)
- Auth checks enforced in protected routes
- No hardcoded secrets or URLs
- User input validated + sanitized
Red flags: Missing auth checks on protected routes, hardcoded secrets, unvalidated user input, XSS/CSRF vulnerabilities
State & Data
- TanStack Query for server state (not client state)
- Error states handled with UI feedback
- Loading states shown during async ops
- Try/catch on all async operations
- Stale data properly managed
Red flags: Server state in client state, missing loading/error states, no try/catch on async
Components & Styling
- Used Flowbite React before custom components
- Light + dark mode support (
dark:classes) - Mobile-first responsive design
- Semantic HTML used
- No hardcoded colors
Red flags: Custom component when Flowbite React exists, missing dark mode, hardcoded colors
React Patterns
- Proper dependency arrays in useEffect/useMemo/useCallback
- Cleanup functions in useEffect (no memory leaks)
- No inline functions causing re-renders
- Feature-based architecture followed
- Components have single responsibility
- State kept close to where it's used
Red flags: Missing useEffect cleanup, inline functions in JSX, props drilling instead of context/store
Libraries & Architecture
- TanStack ecosystem used exclusively
- Feature code stays in
features/until used in 2+ places - No premature abstraction to shared folders
Red flags: Wrong library (Redux, Zustand, React Router, React Hook Form, lodash debounce), premature shared folder promotion
Accessibility
- Keyboard navigation works
- ARIA labels on icon-only buttons
- Form labels properly associated (
htmlFor) - Error messages linked with
aria-describedby - Heading hierarchy is correct
Testing & Quality
- Tests added/updated for changes
- No console.log statements
- No commented-out code
- Biome check passes (
pnpm check) - TypeScript type-check passes
Red flags: console.log in production code, commented-out code