Vite runs inside the frontend container where 'localhost' resolves to the container itself, not the backend. Change the /api proxy target from http://localhost:8000 to http://backend:8000 so the request is routed to the backend service over the compose network.
526 lines
21 KiB
Markdown
526 lines
21 KiB
Markdown
# Frontend Development — Rules & Guidelines
|
||
|
||
Rules and conventions every frontend developer must follow. Read this before writing your first line of code.
|
||
|
||
---
|
||
|
||
## 1. Language & Typing
|
||
|
||
- **TypeScript** is mandatory — no plain JavaScript files (`.js`, `.jsx`) in the codebase.
|
||
- Use **strict mode** (`"strict": true` in `tsconfig.json`) — the project must compile with zero errors.
|
||
- Never use `any`. If a type is truly unknown, use `unknown` and narrow it with type guards.
|
||
- Prefer **interfaces** for object shapes that may be extended, **type aliases** for unions, intersections, and utility types.
|
||
- Every function must have explicit parameter types and return types — including React components (`React.FC` is discouraged; type props and return `JSX.Element` explicitly).
|
||
- Use `T | null` or `T | undefined` instead of `Optional` patterns — be explicit about nullability.
|
||
- Use `as const` for constant literals and enums where it improves type narrowness.
|
||
- Run `tsc --noEmit` in CI — the codebase must pass with zero type errors.
|
||
|
||
```tsx
|
||
// Good
|
||
interface BanEntry {
|
||
ip: string;
|
||
jail: string;
|
||
bannedAt: string;
|
||
expiresAt: string | null;
|
||
}
|
||
|
||
function BanRow({ ban }: { ban: BanEntry }): JSX.Element {
|
||
return <tr><td>{ban.ip}</td><td>{ban.jail}</td></tr>;
|
||
}
|
||
|
||
// Bad — untyped, uses `any`
|
||
function BanRow({ ban }: any) {
|
||
return <tr><td>{ban.ip}</td></tr>;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Reusable Types
|
||
|
||
- All **shared type definitions** live in a dedicated `types/` directory.
|
||
- Group types by domain: `types/ban.ts`, `types/jail.ts`, `types/auth.ts`, `types/api.ts`, etc.
|
||
- Import types using the `import type` syntax when the import is only used for type checking — this keeps the runtime bundle clean.
|
||
- Component-specific prop types may live in the same file as the component, but any type used by **two or more files** must move to `types/`.
|
||
- Never duplicate a type definition — define it once, import everywhere.
|
||
- Export API response shapes alongside their domain types so consumers always know what the server returns.
|
||
|
||
```ts
|
||
// types/ban.ts
|
||
export interface Ban {
|
||
ip: string;
|
||
jail: string;
|
||
bannedAt: string;
|
||
expiresAt: string | null;
|
||
banCount: number;
|
||
country: string | null;
|
||
}
|
||
|
||
export interface BanListResponse {
|
||
bans: Ban[];
|
||
total: number;
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
// components/BanTable.tsx
|
||
import type { Ban } from "../types/ban";
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Type Safety in API Calls
|
||
|
||
- Every API call must have a **typed request** and **typed response**.
|
||
- Define response shapes as TypeScript interfaces in `types/` and cast the response through them.
|
||
- Use a **central API client** (e.g., a thin wrapper around `fetch` or `axios`) that returns typed data — individual components never call `fetch` directly.
|
||
- Validate or assert the response structure at the boundary when dealing with untrusted data; for critical flows, consider a runtime validation library (e.g., `zod`).
|
||
- API endpoint paths are **constants** defined in a single file (`api/endpoints.ts`) — never hard-code URLs in components.
|
||
|
||
```ts
|
||
// api/client.ts
|
||
const BASE_URL = import.meta.env.VITE_API_URL ?? "/api";
|
||
|
||
async function get<T>(path: string): Promise<T> {
|
||
const response: Response = await fetch(`${BASE_URL}${path}`, {
|
||
credentials: "include",
|
||
});
|
||
if (!response.ok) {
|
||
throw new ApiError(response.status, await response.text());
|
||
}
|
||
return (await response.json()) as T;
|
||
}
|
||
|
||
export const api = { get, post, put, del } as const;
|
||
```
|
||
|
||
```ts
|
||
// api/bans.ts
|
||
import type { BanListResponse } from "../types/ban";
|
||
import { api } from "./client";
|
||
|
||
export async function fetchBans(hours: number): Promise<BanListResponse> {
|
||
return api.get<BanListResponse>(`/bans?hours=${hours}`);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Code Organization
|
||
|
||
### Project Structure
|
||
|
||
```
|
||
frontend/
|
||
├── public/
|
||
├── src/
|
||
│ ├── api/ # API client, endpoint definitions, per-domain request files
|
||
│ ├── assets/ # Static images, fonts, icons
|
||
│ ├── components/ # Reusable UI components (buttons, modals, tables, etc.)
|
||
│ ├── hooks/ # Custom React hooks
|
||
│ ├── layouts/ # Page-level layout wrappers (sidebar, header, etc.)
|
||
│ ├── pages/ # Route-level page components (one per route)
|
||
│ ├── providers/ # React context providers (auth, theme, etc.)
|
||
│ ├── theme/ # Fluent UI custom theme, tokens, and overrides
|
||
│ ├── types/ # Shared TypeScript type definitions
|
||
│ ├── utils/ # Pure helper functions, constants, formatters
|
||
│ ├── App.tsx # Root component, FluentProvider + router setup
|
||
│ ├── main.tsx # Entry point
|
||
│ └── vite-env.d.ts # Vite type shims
|
||
├── .eslintrc.cjs
|
||
├── .prettierrc
|
||
├── tsconfig.json
|
||
├── vite.config.ts # Dev proxy: /api → http://backend:8000 (service DNS)
|
||
└── package.json
|
||
```
|
||
|
||
> **Dev proxy target:** `vite.config.ts` proxies all `/api` requests to
|
||
> `http://backend:8000`. Use the compose **service name** (`backend`), not
|
||
> `localhost` — inside the container network `localhost` resolves to the
|
||
> frontend container itself and causes `ECONNREFUSED`.
|
||
|
||
### Separation of Concerns
|
||
|
||
- **Pages** handle routing and compose layout + components — they contain no business logic.
|
||
- **Components** are reusable, receive data via props, and emit changes via callbacks — they never call the API directly.
|
||
- **Hooks** encapsulate stateful logic, side effects, and API calls so components stay declarative.
|
||
- **API layer** handles all HTTP communication — components and hooks consume typed functions from `api/`, never raw `fetch`.
|
||
- **Types** are purely declarative — no runtime code in `types/` files.
|
||
- **Utils** are pure functions with no side effects and no React dependency.
|
||
- **Theme** contains exclusively Fluent UI custom token overrides and theme definitions — no component logic.
|
||
|
||
---
|
||
|
||
## 5. UI Framework — Fluent UI React (v9)
|
||
|
||
- **Fluent UI React Components v9** (`@fluentui/react-components`) is the only UI component library allowed — do not add alternative component libraries (Material UI, Chakra, Ant Design, etc.).
|
||
- Install via npm:
|
||
|
||
```bash
|
||
npm install @fluentui/react-components @fluentui/react-icons
|
||
```
|
||
|
||
### FluentProvider
|
||
|
||
- Wrap the entire application in `<FluentProvider>` at the root — this supplies the theme and design tokens to all Fluent components.
|
||
- The provider must sit above the router so every page inherits the theme.
|
||
|
||
```tsx
|
||
// App.tsx
|
||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||
import { BrowserRouter } from "react-router-dom";
|
||
import AppRoutes from "./AppRoutes";
|
||
|
||
function App(): JSX.Element {
|
||
return (
|
||
<FluentProvider theme={webLightTheme}>
|
||
<BrowserRouter>
|
||
<AppRoutes />
|
||
</BrowserRouter>
|
||
</FluentProvider>
|
||
);
|
||
}
|
||
|
||
export default App;
|
||
```
|
||
|
||
### Theming & Design Tokens
|
||
|
||
- Use the built-in themes (`webLightTheme`, `webDarkTheme`) as the base.
|
||
- Customise design tokens by creating a **custom theme** in `theme/` — never override Fluent styles with raw CSS.
|
||
- Reference tokens via the `tokens` object from `@fluentui/react-components` when writing `makeStyles` rules.
|
||
- If light/dark mode is needed, switch the `theme` prop on `FluentProvider` — never duplicate style definitions for each mode.
|
||
|
||
```ts
|
||
// theme/customTheme.ts
|
||
import { createLightTheme, createDarkTheme } from "@fluentui/react-components";
|
||
import type { BrandVariants, Theme } from "@fluentui/react-components";
|
||
|
||
const brandColors: BrandVariants = {
|
||
10: "#020305",
|
||
// ... define brand colour ramp
|
||
160: "#e8ebf9",
|
||
};
|
||
|
||
export const lightTheme: Theme = createLightTheme(brandColors);
|
||
export const darkTheme: Theme = createDarkTheme(brandColors);
|
||
```
|
||
|
||
### Styling with `makeStyles` (Griffel)
|
||
|
||
- All custom styling is done via `makeStyles` from `@fluentui/react-components` — Fluent UI uses **Griffel** (CSS-in-JS with atomic classes) under the hood.
|
||
- Never use inline `style` props, global CSS, or external CSS frameworks for Fluent components.
|
||
- Co-locate styles in the same file as the component they belong to, defined above the component function.
|
||
- Use `mergeClasses` when combining multiple style sets conditionally.
|
||
- Reference Fluent **design tokens** (`tokens.colorBrandBackground`, `tokens.fontSizeBase300`, etc.) instead of hard-coded values — this ensures consistency and automatic theme support.
|
||
|
||
```tsx
|
||
import { makeStyles, tokens, mergeClasses } from "@fluentui/react-components";
|
||
|
||
const useStyles = makeStyles({
|
||
root: {
|
||
padding: tokens.spacingVerticalM,
|
||
backgroundColor: tokens.colorNeutralBackground1,
|
||
},
|
||
highlighted: {
|
||
backgroundColor: tokens.colorPaletteRedBackground2,
|
||
},
|
||
});
|
||
|
||
function BanCard({ isHighlighted }: BanCardProps): JSX.Element {
|
||
const styles = useStyles();
|
||
return (
|
||
<div className={mergeClasses(styles.root, isHighlighted && styles.highlighted)}>
|
||
{/* ... */}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Component Usage Rules
|
||
|
||
- **Always** prefer Fluent UI components over plain HTML elements for interactive and presentational UI: `<Button>`, `<Input>`, `<Table>`, `<Dialog>`, `<Card>`, `<Badge>`, `<Spinner>`, `<Toast>`, `<MessageBar>`, etc.
|
||
- Use `<DataGrid>` for data-heavy tables (ban lists, jail lists) — it provides sorting, selection, and accessibility out of the box.
|
||
- Use Fluent UI `<Dialog>` for modals and confirmations — never build custom modal overlays.
|
||
- Use `@fluentui/react-icons` for all icons — do not mix icon libraries.
|
||
- Customise Fluent components only through their public API (props, slots, `makeStyles`) — never patch internal DOM or override internal class names.
|
||
|
||
### Libraries you must NOT use alongside Fluent UI
|
||
|
||
- `tailwindcss` — use `makeStyles` and design tokens.
|
||
- `styled-components` / `emotion` — Fluent UI uses Griffel; mixing CSS-in-JS runtimes causes conflicts.
|
||
- `@mui/*`, `antd`, `chakra-ui` — one design system only.
|
||
- Global CSS files that target Fluent class names — use `makeStyles` overrides.
|
||
|
||
---
|
||
|
||
## 6. Component Rules
|
||
|
||
- One component per file. The filename matches the component name: `BanTable.tsx` exports `BanTable`.
|
||
- Use **function declarations** for components — not arrow-function variables.
|
||
- Keep components **small and focused** — if a component exceeds ~150 lines, split it.
|
||
- Props are defined as an `interface` named `<ComponentName>Props` in the same file (or imported from `types/` if shared).
|
||
- Destructure props in the function signature.
|
||
- Never mutate props or state directly — always use immutable update patterns.
|
||
- Avoid inline styles — use `makeStyles` from Fluent UI for all custom styling (see section 5).
|
||
- Supply a `key` prop whenever rendering lists — never use array indices as keys if the list can reorder.
|
||
- Prefer Fluent UI components (`Button`, `Table`, `Input`, …) over raw HTML elements for any interactive or styled element.
|
||
|
||
```tsx
|
||
import { Table, TableBody, TableRow, TableCell, Button } from "@fluentui/react-components";
|
||
import type { Ban } from "../types/ban";
|
||
|
||
interface BanTableProps {
|
||
bans: Ban[];
|
||
onUnban: (ip: string) => void;
|
||
}
|
||
|
||
function BanTable({ bans, onUnban }: BanTableProps): JSX.Element {
|
||
return (
|
||
<Table>
|
||
<TableBody>
|
||
{bans.map((ban) => (
|
||
<TableRow key={ban.ip}>
|
||
<TableCell>{ban.ip}</TableCell>
|
||
<TableCell>{ban.jail}</TableCell>
|
||
<TableCell>
|
||
<Button appearance="subtle" onClick={() => onUnban(ban.ip)}>Unban</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
export default BanTable;
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Hooks & State Management
|
||
|
||
- Prefix custom hooks with `use` — e.g., `useBans`, `useAuth`, `useJails`.
|
||
- Each hook lives in its own file under `hooks/`.
|
||
- Use `useState` for local UI state, `useReducer` for complex state transitions.
|
||
- Use **React Context** sparingly — only for truly global concerns (auth, theme). Do not use context as a replacement for prop drilling one or two levels.
|
||
- Avoid `useEffect` for derived data — compute it during render or use `useMemo`.
|
||
- Always include the correct dependency arrays in `useEffect`, `useMemo`, and `useCallback`. Disable the ESLint exhaustive-deps rule **only** with a comment explaining why.
|
||
- Clean up side effects (subscriptions, timers, abort controllers) in the `useEffect` cleanup function.
|
||
|
||
```tsx
|
||
// hooks/useBans.ts
|
||
import { useState, useEffect } from "react";
|
||
import type { Ban } from "../types/ban";
|
||
import { fetchBans } from "../api/bans";
|
||
|
||
interface UseBansResult {
|
||
bans: Ban[];
|
||
loading: boolean;
|
||
error: string | null;
|
||
}
|
||
|
||
function useBans(hours: number): UseBansResult {
|
||
const [bans, setBans] = useState<Ban[]>([]);
|
||
const [loading, setLoading] = useState<boolean>(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
|
||
async function load(): Promise<void> {
|
||
setLoading(true);
|
||
try {
|
||
const data = await fetchBans(hours);
|
||
setBans(data.bans);
|
||
setError(null);
|
||
} catch (err) {
|
||
if (!controller.signal.aborted) {
|
||
setError(err instanceof Error ? err.message : "Unknown error");
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
load();
|
||
return () => controller.abort();
|
||
}, [hours]);
|
||
|
||
return { bans, loading, error };
|
||
}
|
||
|
||
export default useBans;
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Naming Conventions
|
||
|
||
| Element | Convention | Example |
|
||
|---|---|---|
|
||
| Components | PascalCase | `BanTable`, `JailCard` |
|
||
| Component files | PascalCase `.tsx` | `BanTable.tsx` |
|
||
| Hooks | camelCase with `use` prefix | `useBans`, `useAuth` |
|
||
| Hook files | camelCase `.ts` | `useBans.ts` |
|
||
| Type / Interface | PascalCase | `BanEntry`, `JailListResponse` |
|
||
| Type files | camelCase `.ts` | `ban.ts`, `jail.ts` |
|
||
| Utility functions | camelCase | `formatDate`, `buildQuery` |
|
||
| Constants | UPPER_SNAKE_CASE | `MAX_RETRIES`, `API_BASE_URL` |
|
||
| makeStyles hooks | `useStyles` (file-scoped) | `const useStyles = makeStyles({…})` |
|
||
| makeStyles keys | camelCase slot names | `root`, `header`, `highlighted` |
|
||
| Directories | lowercase kebab‑case or camelCase | `components/`, `hooks/` |
|
||
| Boolean props/variables | `is`/`has`/`should` prefix | `isLoading`, `hasError` |
|
||
|
||
---
|
||
|
||
## 9. Linting & Formatting
|
||
|
||
- **ESLint** with the following plugins is required:
|
||
- `@typescript-eslint/eslint-plugin` — TypeScript-specific rules.
|
||
- `eslint-plugin-react` — React best practices.
|
||
- `eslint-plugin-react-hooks` — enforce rules of hooks.
|
||
- `eslint-plugin-import` — ordered and valid imports.
|
||
- **Prettier** handles all formatting — ESLint must not conflict with Prettier (use `eslint-config-prettier`).
|
||
- Format on save is expected — every developer must enable it in their editor.
|
||
- Run `eslint . --max-warnings 0` and `prettier --check .` in CI — zero warnings, zero formatting diffs.
|
||
- Line length: **100 characters** max.
|
||
- Strings: use **double quotes** (`"`).
|
||
- Semicolons: **always**.
|
||
- Trailing commas: **all** (ES5+).
|
||
- Indentation: **2 spaces**.
|
||
- No unused variables, no unused imports, no `@ts-ignore` without an accompanying comment.
|
||
- Import order (enforced by ESLint): React → third-party → aliases → relative, each group separated by a blank line.
|
||
|
||
```jsonc
|
||
// .prettierrc
|
||
{
|
||
"semi": true,
|
||
"singleQuote": false,
|
||
"trailingComma": "all",
|
||
"printWidth": 100,
|
||
"tabWidth": 2
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Clean Code Principles
|
||
|
||
- **Single Responsibility:** Every function, hook, and component does one thing well.
|
||
- **DRY (Don't Repeat Yourself):** Extract repeated JSX into components, repeated logic into hooks or utils. If you copy-paste, refactor.
|
||
- **KISS (Keep It Simple, Stupid):** Prefer the simplest solution that works. Avoid premature abstraction.
|
||
- **Meaningful Names:** Variable and function names describe **what**, not **how**. Avoid abbreviations (`btn` → `button`, `idx` → `index`) except universally understood ones (`id`, `url`, `ip`).
|
||
- **Small Functions:** If a function exceeds ~30 lines, it likely does too much — split it.
|
||
- **No Magic Numbers / Strings:** Extract constants with descriptive names.
|
||
- **Explicit over Implicit:** Favor clarity over cleverness. Code is written once and read many times.
|
||
- **No Dead Code:** Remove unused functions, commented-out blocks, and unreachable branches before committing.
|
||
- **Early Returns:** Reduce nesting by returning early from guard clauses.
|
||
- **Immutability:** Default to `const`. Use spread / `map` / `filter` instead of mutating arrays and objects.
|
||
|
||
```tsx
|
||
// Bad — magic number, unclear name
|
||
if (data.length > 50) { ... }
|
||
|
||
// Good
|
||
const MAX_VISIBLE_BANS = 50;
|
||
if (data.length > MAX_VISIBLE_BANS) { ... }
|
||
```
|
||
|
||
---
|
||
|
||
## 11. Error Handling
|
||
|
||
- Wrap API calls in `try-catch` inside hooks — components should never see raw exceptions.
|
||
- Display user-friendly error messages — never expose stack traces or raw server responses in the UI.
|
||
- Use an **error boundary** (`ErrorBoundary` component) at the page level to catch unexpected render errors.
|
||
- Log errors to the console (or a future logging service) with sufficient context for debugging.
|
||
- Always handle the **loading**, **error**, and **empty** states for every data-driven component.
|
||
|
||
---
|
||
|
||
## 12. Performance
|
||
|
||
- Use `React.memo` only when profiling reveals unnecessary re-renders — do not wrap every component by default.
|
||
- Use `useMemo` and `useCallback` for expensive computations and stable callback references passed to child components — not for trivial values.
|
||
- Lazy-load route-level pages with `React.lazy` + `Suspense` to reduce initial bundle size.
|
||
- Avoid creating new objects or arrays inside render unless necessary — stable references prevent child re-renders.
|
||
- Keep bundle size in check — review dependencies before adding them and prefer lightweight alternatives.
|
||
|
||
---
|
||
|
||
## 13. Accessibility
|
||
|
||
- Use semantic HTML elements (`<button>`, `<nav>`, `<table>`, `<main>`, `<header>`) — not `<div>` with click handlers.
|
||
- Every interactive element must be **keyboard accessible** (focusable, operable with Enter/Space/Escape as appropriate).
|
||
- Images and icons require `alt` text or `aria-label`.
|
||
- Form inputs must have associated `<label>` elements.
|
||
- Maintain sufficient color contrast ratios (WCAG AA minimum).
|
||
- Test with a screen reader periodically.
|
||
|
||
---
|
||
|
||
## 14. Testing
|
||
|
||
- Write tests for every new component, hook, and utility function.
|
||
- Use **Vitest** (or Jest) as the test runner and **React Testing Library** for component tests.
|
||
- Test **behavior**, not implementation — query by role, label, or text, never by CSS class or internal state.
|
||
- Mock API calls at the network layer (e.g., `msw` — Mock Service Worker) — components should not know about mocks.
|
||
- Aim for **>80 % line coverage** — critical paths (auth flow, ban/unban actions) must be 100 %.
|
||
- Test name pattern: `it("should <expected behavior> when <condition>")`.
|
||
- Wrap components under test in `<FluentProvider>` so Fluent UI styles and tokens resolve correctly:
|
||
|
||
```tsx
|
||
import { render, screen } from "@testing-library/react";
|
||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||
import BanTable from "./BanTable";
|
||
import type { Ban } from "../types/ban";
|
||
|
||
const mockBans: Ban[] = [
|
||
{ ip: "192.168.1.1", jail: "sshd", bannedAt: "2026-02-28T12:00:00Z", expiresAt: null, banCount: 3, country: "DE" },
|
||
];
|
||
|
||
function renderWithProvider(ui: JSX.Element) {
|
||
return render(<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>);
|
||
}
|
||
|
||
it("should render a row for each ban", () => {
|
||
renderWithProvider(<BanTable bans={mockBans} onUnban={vi.fn()} />);
|
||
expect(screen.getByText("192.168.1.1")).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 15. Git & Workflow
|
||
|
||
- **Branch naming:** `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>`.
|
||
- **Commit messages:** imperative tense, max 72 chars first line (`Add ban table component`, `Fix date formatting in dashboard`).
|
||
- Every merge request must pass: ESLint, Prettier, TypeScript compiler, all tests.
|
||
- Do not merge with failing CI.
|
||
- Keep pull requests small and focused — one feature or fix per PR.
|
||
- Review your own diff before requesting review.
|
||
|
||
---
|
||
|
||
## 16. Quick Reference — Do / Don't
|
||
|
||
| Do | Don't |
|
||
|---|---|
|
||
| Type every prop, state, and return value | Use `any` or leave types implicit |
|
||
| Keep shared types in `types/` | Duplicate interfaces across files |
|
||
| Call API from hooks, not components | Scatter `fetch` calls across the codebase |
|
||
| Use `import type` for type-only imports | Import types as regular imports |
|
||
| One component per file | Export multiple components from one file |
|
||
| Destructure props in the signature | Access `props.x` throughout the body |
|
||
| Use Fluent UI components for all interactive UI | Build custom buttons, inputs, dialogs from scratch |
|
||
| Style with `makeStyles` + design tokens | Use inline styles, global CSS, or Tailwind |
|
||
| Wrap the app in `<FluentProvider>` | Render Fluent components outside a provider |
|
||
| Use `@fluentui/react-icons` for icons | Mix multiple icon libraries |
|
||
| Use semantic HTML elements | Use `<div>` for everything |
|
||
| Handle loading, error, and empty states | Only handle the happy path |
|
||
| Name booleans with `is`/`has`/`should` | Name booleans as plain nouns (`loading`) |
|
||
| Extract constants for magic values | Hard-code numbers and strings |
|
||
| Clean up effects (abort, unsub) | Let effects leak resources |
|
||
| Format and lint before every commit | Push code that doesn't pass CI |
|